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>
|
||
|---|---|---|
| .. | ||
| __init__.py | ||
| addon.py | ||
| cache_key.py | ||
| Dockerfile | ||
| README.md | ||
| redis_store.py | ||
| trust_ca.sh | ||
e2e cassette proxy
A sidecar HTTP/HTTPS proxy that records and replays upstream responses
in Redis. Designed for CircleCI e2e jobs whose system-under-test runs
inside Docker — the in-process VCR persister at
tests/_vcr_redis_persister.py can't see those requests, this can.
What it caches
Every HTTPS egress that flows through the proxy and:
- uses
GET/POST/PUT/PATCH/DELETE - is not destined for
localhost,127.0.0.1,host.docker.internal, or any host listed inLITELLM_E2E_CASS_PASSTHROUGH_HOSTS - received a
2xxresponse from the upstream
is keyed on a canonical hash of (method, scheme, host, path, sorted query, allowlisted headers, canonical body) and stored in Redis. On
subsequent runs, matching requests are served straight from Redis
without ever hitting the upstream.
What's intentionally not keyed on (so cache hits survive normal churn):
Authorization,x-api-key,anthropic-api-key,openai-api-key,azure-api-key,cookie, AWS sigv4 headers,x-goog-api-key,x-goog-user-project— auth rotates every runUser-Agent,x-stainless-*,traceparent,tracestate,x-request-id,request-id— tracing / SDK metadata- JSON key order or whitespace inside the request body — bodies are re-serialized in canonical form before hashing
How to opt a CI job in
Two changes to the job in .circleci/config.yml:
-
Add the
start_cassette_proxyreusable command after your other sidecars (postgres, redis, etc.) and before you start the container under test:- start_postgres - start_cassette_proxy -
When you
docker runthe container under test, route its egress through the sidecar and trust its CA:docker run -d \ ...your existing env... -e HTTP_PROXY="$CASSETTE_PROXY_URL" \ -e HTTPS_PROXY="$CASSETTE_PROXY_URL" \ -e NO_PROXY="localhost,127.0.0.1,host.docker.internal" \ -e SSL_CERT_FILE=/etc/litellm-cassette-proxy-ca.crt \ -e REQUESTS_CA_BUNDLE=/etc/litellm-cassette-proxy-ca.crt \ -e CURL_CA_BUNDLE=/etc/litellm-cassette-proxy-ca.crt \ -e AWS_CA_BUNDLE=/etc/litellm-cassette-proxy-ca.crt \ -e NODE_EXTRA_CA_CERTS=/etc/litellm-cassette-proxy-ca.crt \ -v "$CASSETTE_PROXY_CA":/etc/litellm-cassette-proxy-ca.crt:ro \ ...your image and command...
e2e_openai_endpoints is the canonical example in this PR. To opt the
others (proxy_e2e_anthropic_messages_tests,
proxy_pass_through_endpoint_tests, e2e_ui_testing,
google_generate_content_endpoint_testing,
proxy_logging_guardrails_model_info_tests,
proxy_multi_instance_tests, proxy_spend_accuracy_tests,
proxy_store_model_in_db_tests) in, copy the same two changes.
Knobs
| Env var | Where set | Effect |
|---|---|---|
LITELLM_E2E_CASS_REDIS_URL |
sidecar container | Override the Redis URL the sidecar uses to store cassettes. Falls back to REDIS_URL / REDIS_SSL_URL / REDIS_HOST + REDIS_PORT + REDIS_PASSWORD. |
LITELLM_E2E_CASS_PASSTHROUGH_HOSTS |
sidecar container | Extra hosts (comma-separated) to never cache. |
LITELLM_E2E_CASS_RECORD_ONLY |
sidecar container | When 1, never serve from cache; always forward + persist. Use during cassette refresh runs. |
LITELLM_E2E_CASS_REPLAY_ONLY |
sidecar container | When 1, never forward to upstream; serve 599 on miss. Use to prove a job is fully cached. |
Why mitmproxy and not vcrpy
vcrpy is a Python in-process monkey-patch on httpx/aiohttp/httpcore.
It can only intercept HTTP traffic in the same process where it was
installed. Every CI job that runs the LiteLLM proxy in a Docker container
issues its upstream traffic from that container's process — not the
pytest process — so vcrpy literally has no hook to attach to.
A network-level recording proxy is language-agnostic, in-process-agnostic, and transport-agnostic. It works for the LiteLLM proxy (Python aiohttp), for the websocket realtime tests (a separate server), for the OpenAI SDK (node fetch in some paths), and for any future containerized e2e job without any changes to the SUT.
Why one Redis key per request, not vcrpy-style cassettes
vcrpy stores an ordered list of (request, response) episodes per test
file. That model is the source of every footgun the
tests/llm_translation/ recorder hit (unbounded per-key growth in
new_episodes mode, ordering brittleness, OOM under noeviction). Here
each Redis key holds exactly one (request_summary, response) pair,
so:
- Per-key size is bounded by one response (with an explicit
max_payload_bytesceiling on top). - Two tests issuing the same request share the cache entry for free.
- "Order" is no longer a thing.
- "Record mode" is no longer a thing — if the entry is present, replay; else record.
Refreshing cassettes
Add the LITELLM_E2E_CASS_RECORD_ONLY=1 env var to the
start_cassette_proxy docker run flags for one CI run; every cassette
the job exercises will be re-recorded. Or wipe specific keys with
redis-cli del litellm:e2ecass:<sha256>.