mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
Introduces a mitmproxy-based recording HTTP/HTTPS sidecar that any CI
job can opt into to cache LLM-provider responses across runs. Unlike
the in-process VCR persister at tests/_vcr_redis_persister.py — which
can only intercept HTTP traffic from the same Python process where it
was loaded — this sidecar operates at the network layer, so it works
for any e2e job whose system-under-test runs in a Docker container
(every job under e2e_*, proxy_*, etc.).
Components
- tests/e2e_cassette_proxy/cache_key.py: pure-function cache-key
derivation. Hashes (method, scheme, host, path, sorted query,
allowlisted headers, canonical-JSON body); strips auth, tracing,
and SDK-metadata headers so equivalent requests collide regardless
of run-to-run noise.
- tests/e2e_cassette_proxy/redis_store.py: thin Redis wrapper that
stores one (request, response) pair per key as MessagePack
(JSON+base64 fallback). Caps per-key payload size, drops oversize
responses with a log line, and never blocks the request path on
Redis errors.
- tests/e2e_cassette_proxy/addon.py: mitmproxy addon that ties the
two together. Hosts on the passthrough list (localhost, the proxy
itself) are never cached; non-2xx upstream responses are not
persisted.
- tests/e2e_cassette_proxy/Dockerfile: pinned python:3.12-slim base +
pinned mitmproxy 11.0.2 + pinned redis-py + pinned msgpack.
- tests/e2e_cassette_proxy/trust_ca.sh: helper for SUT containers to
trust the proxy CA in every Python / curl / boto3 / node trust
store at once.
- tests/e2e_cassette_proxy/README.md: usage guide + opt-in checklist
for other e2e jobs.
CI integration
- New reusable command 'start_cassette_proxy' in .circleci/config.yml.
Builds the image, runs the sidecar wired to the project Redis, fetches
the proxy CA, and exports CASSETTE_PROXY_URL / CASSETTE_PROXY_CA into
$BASH_ENV for downstream steps.
- e2e_openai_endpoints is wired up as the canonical demo: two-line opt-in
pattern documented in the README.
- Job logs include a 'Cassette-proxy stats' step that dumps the
hit/miss/store summary via 'docker logs cassette-proxy | grep
[E2ECASS]'.
Tests
- 31 hermetic unit tests under tests/test_litellm/e2e_cassette_proxy:
- test_cache_key.py: 14 tests pinning equivalence-class behavior of
the key derivation (auth header, tracing header, JSON key order,
query order, host case all collapse; method/path/body/allowlisted
headers / query-param values do not).
- test_redis_store.py: 9 tests covering set/get round-trip, default
TTL, binary body round-trip, oversize-payload rejection, corrupt-
blob eviction, and graceful behavior when the Redis client raises.
- test_addon.py: 8 tests using a fake-mitmproxy flow to exercise
the addon end-to-end (passthrough miss, persist on 2xx, hit on
canonicalized-equivalent re-request, no-persist on 5xx, host
passthrough, replay-only 599-on-miss, record-only never serves
cache).
- 31/31 pass.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
193 lines
6.7 KiB
Python
193 lines
6.7 KiB
Python
"""mitmproxy addon: cache HTTPS request/response pairs in Redis.
|
|
|
|
Loaded by ``mitmdump -s addon.py``. Two hooks:
|
|
|
|
- ``request(flow)``: derive cache key, look up in Redis, short-circuit
|
|
the response if we have one.
|
|
- ``response(flow)``: persist the upstream's response under that same
|
|
cache key for the next run.
|
|
|
|
This intentionally caches *every* upstream call that flows through the
|
|
proxy, regardless of host. The expensive surface (LLM provider APIs)
|
|
is what we care about, but capturing everything also dedupes the
|
|
boring stuff (token endpoints, model-list calls, control-plane pings)
|
|
for free.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Optional
|
|
|
|
from mitmproxy import ctx, http # type: ignore[import-not-found]
|
|
|
|
from tests.e2e_cassette_proxy.cache_key import (
|
|
DEFAULT_HEADER_ALLOWLIST,
|
|
DEFAULT_HEADER_BLOCKLIST,
|
|
derive_cache_key,
|
|
)
|
|
from tests.e2e_cassette_proxy.redis_store import (
|
|
CachedResponse,
|
|
RedisCassetteStore,
|
|
)
|
|
|
|
_log = logging.getLogger("litellm.e2e_cassette_proxy.addon")
|
|
if not _log.handlers:
|
|
_h = logging.StreamHandler()
|
|
_h.setFormatter(logging.Formatter("[E2ECASS] %(message)s"))
|
|
_log.addHandler(_h)
|
|
_log.setLevel(logging.INFO)
|
|
_log.propagate = False
|
|
|
|
|
|
# Hosts we should *never* cache — Redis itself, the proxy admin UI,
|
|
# anything pointed at localhost. Extended via env var
|
|
# ``LITELLM_E2E_CASS_PASSTHROUGH_HOSTS`` (comma-separated).
|
|
_PASSTHROUGH_HOSTS = {
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"0.0.0.0",
|
|
"host.docker.internal",
|
|
}
|
|
|
|
|
|
def _passthrough_hosts_from_env() -> set[str]:
|
|
raw = os.environ.get("LITELLM_E2E_CASS_PASSTHROUGH_HOSTS", "")
|
|
return {h.strip().lower() for h in raw.split(",") if h.strip()}
|
|
|
|
|
|
def _record_only() -> bool:
|
|
return os.environ.get("LITELLM_E2E_CASS_RECORD_ONLY", "").lower() in ("1", "true")
|
|
|
|
|
|
def _replay_only() -> bool:
|
|
return os.environ.get("LITELLM_E2E_CASS_REPLAY_ONLY", "").lower() in ("1", "true")
|
|
|
|
|
|
def _shape_summary(flow: http.HTTPFlow) -> str:
|
|
return f"{flow.request.method} {flow.request.pretty_host}{flow.request.path}"
|
|
|
|
|
|
def _is_2xx(status_code: int) -> bool:
|
|
return 200 <= status_code < 300
|
|
|
|
|
|
class CassetteAddon:
|
|
"""mitmproxy addon class. Created once at startup; ``request`` and
|
|
``response`` are invoked per flow."""
|
|
|
|
def __init__(self, store: Optional[RedisCassetteStore] = None) -> None:
|
|
self._store: Optional[RedisCassetteStore] = store
|
|
self._passthrough = _PASSTHROUGH_HOSTS | _passthrough_hosts_from_env()
|
|
self._record_only = _record_only()
|
|
self._replay_only = _replay_only()
|
|
self._stats = {"hit": 0, "miss": 0, "stored": 0, "skipped": 0}
|
|
|
|
def load(self, loader) -> None: # noqa: ARG002 - mitmproxy hook signature
|
|
if self._store is None:
|
|
try:
|
|
self._store = RedisCassetteStore()
|
|
except Exception as exc:
|
|
_log.info(
|
|
f"redis-init-failed err={type(exc).__name__}: {exc}; "
|
|
f"running in passthrough mode"
|
|
)
|
|
self._store = None
|
|
_log.info(
|
|
f"loaded record_only={self._record_only} replay_only={self._replay_only} "
|
|
f"passthrough_hosts={sorted(self._passthrough)}"
|
|
)
|
|
|
|
def done(self) -> None:
|
|
_log.info(
|
|
f"summary hits={self._stats['hit']} misses={self._stats['miss']} "
|
|
f"stored={self._stats['stored']} skipped={self._stats['skipped']}"
|
|
)
|
|
|
|
def _should_skip(self, flow: http.HTTPFlow) -> bool:
|
|
host = flow.request.pretty_host.lower()
|
|
if host in self._passthrough:
|
|
return True
|
|
# CONNECT tunnels are handled implicitly by mitmproxy; only filter
|
|
# the inner HTTP request/response. Method allowlist keeps the keys
|
|
# readable in Redis.
|
|
if flow.request.method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
|
return True
|
|
return False
|
|
|
|
def _key_for(self, flow: http.HTTPFlow) -> str:
|
|
return derive_cache_key(
|
|
method=flow.request.method,
|
|
url=flow.request.pretty_url,
|
|
body=flow.request.raw_content or b"",
|
|
headers=dict(flow.request.headers),
|
|
allowlist=DEFAULT_HEADER_ALLOWLIST,
|
|
blocklist=DEFAULT_HEADER_BLOCKLIST,
|
|
)
|
|
|
|
def request(self, flow: http.HTTPFlow) -> None:
|
|
if self._store is None or self._should_skip(flow):
|
|
self._stats["skipped"] += 1
|
|
return
|
|
if self._record_only:
|
|
return
|
|
key = self._key_for(flow)
|
|
cached = self._store.get(key)
|
|
if cached is None:
|
|
self._stats["miss"] += 1
|
|
_log.info(f"miss key={key} {_shape_summary(flow)}")
|
|
if self._replay_only:
|
|
# Don't fall through to the upstream; serve a stable 599 so
|
|
# the test surfaces the missing recording loudly.
|
|
flow.response = http.Response.make(
|
|
599,
|
|
b"e2e-cassette-proxy: replay-only and no recording for this request",
|
|
{"content-type": "text/plain"},
|
|
)
|
|
return
|
|
_log.info(
|
|
f"hit key={key} {_shape_summary(flow)} "
|
|
f"status={cached.status_code} bytes={len(cached.body)}"
|
|
)
|
|
self._stats["hit"] += 1
|
|
flow.response = http.Response.make(
|
|
cached.status_code,
|
|
cached.body,
|
|
list(cached.headers),
|
|
)
|
|
|
|
def response(self, flow: http.HTTPFlow) -> None:
|
|
if self._store is None or self._should_skip(flow):
|
|
return
|
|
if self._replay_only:
|
|
return
|
|
# If we already served from cache, don't re-store.
|
|
if flow.response is None or flow.response.status_code == 599:
|
|
return
|
|
# Don't cache redirects, errors, or rate-limit responses — they're
|
|
# not useful for replay and would just churn keys.
|
|
if not _is_2xx(flow.response.status_code):
|
|
return
|
|
key = self._key_for(flow)
|
|
cached = CachedResponse(
|
|
status_code=flow.response.status_code,
|
|
headers=tuple((k, v) for k, v in flow.response.headers.items()),
|
|
body=flow.response.raw_content or b"",
|
|
reason=flow.response.reason or "",
|
|
)
|
|
if self._store.set(key, cached):
|
|
self._stats["stored"] += 1
|
|
_log.info(
|
|
f"store key={key} {_shape_summary(flow)} "
|
|
f"status={cached.status_code} bytes={len(cached.body)}"
|
|
)
|
|
|
|
|
|
# mitmproxy entrypoint.
|
|
addons = [CassetteAddon()]
|
|
|
|
|
|
# Re-exported for parity with mitmproxy quirks (some versions look for
|
|
# ``ctx`` symbol availability at import time).
|
|
__all__ = ["CassetteAddon", "addons", "ctx"]
|