litellm/tests/e2e_cassette_proxy
Cursor Agent 765aef4ff8
tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs
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>
2026-05-01 14:41:28 +00:00
..
__init__.py tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
addon.py tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
cache_key.py tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
Dockerfile tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
README.md tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
redis_store.py tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00
trust_ca.sh tests(e2e): add transport-agnostic recording proxy sidecar for e2e CI jobs 2026-05-01 14:41:28 +00:00

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 in LITELLM_E2E_CASS_PASSTHROUGH_HOSTS
  • received a 2xx response 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 run
  • User-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:

  1. Add the start_cassette_proxy reusable command after your other sidecars (postgres, redis, etc.) and before you start the container under test:

    - start_postgres
    - start_cassette_proxy
    
  2. When you docker run the 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_bytes ceiling 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>.