litellm/tests/_vcr_redis_persister.py
Sameer Kankute 477b63c5ea
fix(caching): replay openai/responses bridge cache hits as chat streams (#28158)
* fix(caching): replay openai/responses bridge cache hits as chat streams

When chat completions route through openai/responses, cached ModelResponse
payloads under aresponses keys were deserialized as ResponsesAPIResponse
(500) or re-translated as responses events (empty streaming deltas). Deserialize
chat-shaped cache entries as acompletion and bypass the responses stream iterator
for cached CustomStreamWrapper replay.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(caching): map responses bridge call_type for sync vs async stream replay

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix: handle ModelResponse cache return in responses bridge and drop dead acompletion check

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(caching): detect chat cache hits via object field before choices fallback

Prefer chat.completion object type over the broad choices-key heuristic so
Responses API cached payloads are not misclassified if their schema changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(caching): cover responses bridge cache-hit paths in CI-tracked test suite

The new bridge cache replay logic in caching_handler.py and the
preformatted-stream guard in litellm_responses_transformation/handler.py
were exercised only by tests under tests/local_testing/, which the
responses-caching-types and misc shards do not run. Codecov flagged the
patch as 29.72% covered.

Add equivalent unit tests under tests/test_litellm/ so the responses,
caching, types, and misc shards execute them and ship their coverage
data to Codecov:

- _is_chat_completion_cached_dict happy/sad paths
- aresponses streaming bridge cache hit -> CustomStreamWrapper
- responses non-streaming bridge cache hit -> ModelResponse
- legacy ResponsesAPIResponse stream + non-stream replay
- _is_preformatted_cached_chat_stream true/false
- completion/acompletion early return on cached ModelResponse
- completion/acompletion skip rewrap on preformatted cached stream

* fix: add negative guard on object field in _is_chat_completion_cached_dict

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(vcr): treat corrupt cassette payloads as cache miss

* test: bump EOL'd NVIDIA rerank and OpenAI realtime models in CI

The NVIDIA hosted rerank endpoint for nvidia/llama-3_2-nv-rerankqa-1b-v2
reached end-of-life on 2026-05-18 and now returns HTTP 410 Gone, breaking
TestNvidiaNim::test_basic_rerank. Switch to nvidia/nv-rerankqa-mistral-4b-v3,
which is still hosted on the NVIDIA API catalog and is already listed in
model_prices_and_context_window.json.

OpenAI also retired the gpt-4o-realtime-preview-2024-12-17 model used by
test_realtime_guardrails_openai (now returns model_not_found). Switch the
realtime test URL to the GA gpt-realtime alias.

Unrelated to the responses-bridge cache fix in this PR, but committing
here to unblock CI per maintainer guidance.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(realtime): switch retired gpt-4o-realtime-preview to gpt-realtime

OpenAI removed gpt-4o-realtime-preview and all its date snapshots on
2026-05-18 (every variant now returns model_not_found), breaking the
live-WebSocket OpenAI realtime tests in CI:

  - test_openai_realtime_direct_call_no_intent
  - test_openai_realtime_direct_call_with_intent
  - TestOpenAIRealtime.test_realtime_connection
  - TestOpenAIRealtime.test_realtime_with_query_params

Point each of those to the current GA alias gpt-realtime (verified live).
Pure unit/mock tests that just assert the string value (e.g. in
test_realtime_query_params_construction and the
test_realtime_query_params_use_normalized_model_name mock) are left
alone since they do not depend on model availability.

Also relax the AI-response assertion in
test_text_message_blocked_by_guardrail_no_ai_response: gpt-realtime
occasionally produces a polite refusal ("I'm sorry, but I can't say
that") when the cancel arrives after the model has already started
generating, which is the expected outcome (no real AI content) but does
not contain the words 'blocked' or 'guardrail'. The primary guardrail
behaviour (guardrail_violation error event + transcript_delta block
message) is still asserted unchanged.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(nvidia_nim): mock rerank live API instead of hitting EOL'd endpoint

NVIDIA reached end-of-life for the hosted nvidia/llama-3.2-nv-rerankqa-1b-v2
rerank API on 2026-05-18 (returns HTTP 410 Gone), and the proposed
replacement nv-rerankqa-mistral-4b-v3 returns HTTP 404 for the CI account,
breaking TestNvidiaNim::test_basic_rerank.

Override test_basic_rerank to mock the HTTP transport (same pattern as
test_nvidia_nim_rerank_ranking_endpoint above) so the request/response
transformation and cost calculation stay covered without depending on
NVIDIA's hosted catalog rotation. The model identifier reverts to the
original llama-3.2-nv-rerankqa-1b-v2 since the request never leaves
the test process.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
2026-05-18 16:27:06 -07:00

276 lines
10 KiB
Python

from __future__ import annotations
import logging
import os
import warnings
from typing import Any, Optional
from vcr.persisters.filesystem import CassetteNotFoundError
from vcr.serialize import deserialize, serialize
CASSETTE_TTL_SECONDS = 24 * 60 * 60
REDIS_KEY_PREFIX = "litellm:vcr:cassette:"
CASSETTE_REDIS_URL_ENV = "CASSETTE_REDIS_URL"
VCR_VERBOSE_ENV = "LITELLM_VCR_VERBOSE"
MAX_EPISODES_PER_CASSETTE = 50
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_log = logging.getLogger(__name__)
_passed_by_cassette_key: dict[str, bool] = {}
class VCRCassetteCacheWarning(UserWarning):
"""Emitted when the cassette Redis cache fails to load or save.
Surfaced in pytest's session-end warnings summary so failures are
visible in CI logs even when the underlying tests pass.
"""
# Per-process counters; surfaced via :func:`cassette_cache_health` so
# conftests can emit a session-end banner when failures occurred.
_cache_health = {
"save_failures": 0,
"save_failure_last_error": "",
"load_failures": 0,
"load_failure_last_error": "",
}
def _record_cache_failure(kind: str, exc: BaseException) -> None:
err = f"{type(exc).__name__}: {exc}"
if kind == "save":
_cache_health["save_failures"] = int(_cache_health["save_failures"]) + 1
_cache_health["save_failure_last_error"] = err
elif kind == "load":
_cache_health["load_failures"] = int(_cache_health["load_failures"]) + 1
_cache_health["load_failure_last_error"] = err
def cassette_cache_health() -> dict:
return dict(_cache_health)
def reset_cassette_cache_health() -> None:
_cache_health["save_failures"] = 0
_cache_health["save_failure_last_error"] = ""
_cache_health["load_failures"] = 0
_cache_health["load_failure_last_error"] = ""
def cassette_cache_capacity_snapshot(client: Optional[Any] = None) -> Optional[dict]:
"""Probe Redis ``INFO memory`` and return used/max bytes and percent.
Returns ``None`` if Redis is unreachable, the server didn't report
``maxmemory``, or ``maxmemory`` is 0 (uncapped). Best-effort: any
exception turns into ``None`` so this never breaks a test session.
"""
try:
if client is None:
client = _build_default_client()
info = client.info(section="memory")
except Exception: # pragma: no cover - best-effort probe
return None
used = info.get("used_memory")
maxmem = info.get("maxmemory")
try:
used = int(used) if used is not None else None
maxmem = int(maxmem) if maxmem is not None else None
except (TypeError, ValueError): # pragma: no cover - defensive
return None
if not used or not maxmem or maxmem <= 0:
return None
return {
"used_memory_bytes": used,
"maxmemory_bytes": maxmem,
"used_pct": (used / maxmem) * 100.0,
}
def mark_test_outcome_for_cassette(cassette_path: str, passed: bool) -> None:
_passed_by_cassette_key[redis_key_for(cassette_path)] = passed
def redis_key_for(cassette_path: str) -> str:
abs_path = os.path.abspath(str(cassette_path))
try:
rel = os.path.relpath(abs_path, start=_REPO_ROOT)
except ValueError:
rel = os.path.basename(abs_path)
if rel.endswith(".yaml"):
rel = rel[: -len(".yaml")]
rel = rel.replace("/cassettes/", "/").lstrip("./")
return f"{REDIS_KEY_PREFIX}{rel}"
def _redis_url_from_env() -> Optional[str]:
return os.environ.get(CASSETTE_REDIS_URL_ENV) or None
def _build_default_client():
import redis
from redis.backoff import ExponentialBackoff
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
from redis.retry import Retry
url = _redis_url_from_env()
if not url:
raise RuntimeError(
f"Set {CASSETTE_REDIS_URL_ENV} to enable the VCR persister. "
"Cassette Redis is intentionally separate from the application "
"Redis (REDIS_URL/REDIS_HOST) to avoid being flushed by tests."
)
return redis.Redis.from_url(
url,
socket_timeout=5,
socket_connect_timeout=5,
decode_responses=False,
retry=Retry(ExponentialBackoff(cap=2, base=0.1), retries=2),
retry_on_error=[RedisConnectionError, RedisTimeoutError],
)
def make_redis_persister(
client: Optional[Any] = None,
ttl_seconds: int = CASSETTE_TTL_SECONDS,
):
redis_client = client if client is not None else _build_default_client()
try:
from redis.exceptions import RedisError
except ImportError: # pragma: no cover - redis is a hard test dep
RedisError = Exception # type: ignore[assignment,misc]
class _RedisPersister:
@staticmethod
def load_cassette(cassette_path, serializer):
try:
data = redis_client.get(redis_key_for(cassette_path))
except RedisError as exc:
_record_cache_failure("load", exc)
msg = (
f"VCR redis load failed for {cassette_path}; treating "
f"as cache miss: {type(exc).__name__}: {exc}"
)
_log.warning(msg)
warnings.warn(msg, VCRCassetteCacheWarning, stacklevel=2)
raise CassetteNotFoundError() from exc
if data is None:
raise CassetteNotFoundError()
try:
if isinstance(data, bytes):
data = data.decode("utf-8")
return deserialize(data, serializer)
except Exception as exc:
_record_cache_failure("load", exc)
msg = (
f"VCR redis load failed for {cassette_path}; cached "
f"payload is corrupt, treating as cache miss: "
f"{type(exc).__name__}: {exc}"
)
_log.warning(msg)
warnings.warn(msg, VCRCassetteCacheWarning, stacklevel=2)
raise CassetteNotFoundError() from exc
@staticmethod
def save_cassette(cassette_path, cassette_dict, serializer):
key = redis_key_for(cassette_path)
passed = _passed_by_cassette_key.pop(key, True)
episode_count = len(cassette_dict.get("requests", []) or [])
if episode_count > MAX_EPISODES_PER_CASSETTE:
_log.warning(
"VCR redis save refused for %s; cassette has %d episodes "
"(> MAX_EPISODES_PER_CASSETTE=%d). The test likely produces "
"non-deterministic request bodies (e.g. uuid) and is "
"appending instead of replaying. Opt it out with the "
"no-vcr list in conftest, or stabilize its request body.",
cassette_path,
episode_count,
MAX_EPISODES_PER_CASSETTE,
)
return
if not passed:
_log.info(
"VCR redis save skipped for %s; test did not pass — "
"leaving any prior cassette intact",
cassette_path,
)
return
data = serialize(cassette_dict, serializer)
payload = data.encode("utf-8") if isinstance(data, str) else data
try:
redis_client.set(key, payload, ex=ttl_seconds)
except RedisError as exc:
# Cassette persistence is strictly best-effort: connection
# blips, timeouts, OOM at the maxmemory cap, READONLY
# replicas, etc. should all degrade gracefully to "test
# passed but cassette not cached" rather than failing the
# test on teardown. We still want a loud signal so the
# failure shows up in pytest's warnings summary at the
# end of the session and feeds the session-end banner.
_record_cache_failure("save", exc)
msg = (
f"VCR redis save failed for {cassette_path}; cassette "
f"not persisted: {type(exc).__name__}: {exc}"
)
_log.warning(msg)
warnings.warn(msg, VCRCassetteCacheWarning, stacklevel=2)
return _RedisPersister
def filter_non_2xx_response(response):
if not isinstance(response, dict):
return response
status = response.get("status")
code = status.get("code") if isinstance(status, dict) else status
if not isinstance(code, int):
return response
return response if 200 <= code < 300 else None
_PATCHED_AIOHTTP_RECORD = False
def patch_vcrpy_aiohttp_record_path() -> None:
"""Re-feed the response body into aiohttp's StreamReader after vcrpy's
record_response drains it, so downstream consumers (e.g.
LiteLLMAiohttpTransport.AiohttpResponseStream) can still read it."""
global _PATCHED_AIOHTTP_RECORD
if _PATCHED_AIOHTTP_RECORD:
return
import vcr.stubs.aiohttp_stubs as _aiohttp_stubs
_orig_record_response = _aiohttp_stubs.record_response
async def _record_response_preserving_body(cassette, vcr_request, response):
await _orig_record_response(cassette, vcr_request, response)
body = getattr(response, "_body", None) or b""
if body:
response.content.unread_data(body)
_aiohttp_stubs.record_response = _record_response_preserving_body
_PATCHED_AIOHTTP_RECORD = True
def vcr_verbose_enabled() -> bool:
return os.environ.get(VCR_VERBOSE_ENV) == "1"
def format_vcr_verdict(cassette: Any) -> str:
if cassette is None:
return "[VCR NOOP]"
played = getattr(cassette, "play_count", 0) or 0
dirty = getattr(cassette, "dirty", False)
total = len(cassette) if hasattr(cassette, "__len__") else 0
if played == 0 and not dirty:
return "[VCR NOOP] (no http traffic)"
if played > 0 and not dirty:
return f"[VCR HIT] {played} replayed, 0 new ({total} cassette entries)"
if played == 0 and dirty:
return f"[VCR MISS] 0 replayed, recorded new ({total} cassette entries)"
return (
f"[VCR PARTIAL] {played} replayed + new recordings ({total} cassette entries)"
)