E2E_FIXTURE_MODE selects the transport every e2e client is built on: live (default, unchanged behavior), record (pass through to the live proxy while writing every interaction to a fixture bundle), or replay (serve every interaction from the bundle with no proxy and no provider spend). Both new transports fulfil the existing Transport protocol, so no test changes shape. A bundle is a directory with a manifest (record timestamp, harness version, format version) and one JSON file per interaction, grouped per test in call order. Replay against a manifest older than seven days hard-fails at collection time naming the bundle age. Record always wipes and never reads the previous bundle, refusing to wipe a directory that is not a bundle. Auth header values are redacted on write; uploads store a sha256 digest. unique_marker() becomes deterministic per test in record/replay modes so a replay run regenerates exactly the requests the record run sent. Content-based match keys, streaming chunk fidelity, and provider-scoping are follow-ups (LIT-5741, LIT-5742, LIT-5745).
11 KiB
Contributors Guide
This directory holds the live end-to-end suites that prove product correctness against a real running proxy and real provider APIs. The goal of this guide is simple: when you ship a feature, you add e2e coverage that walks that feature the way production does, across every route and edge case it touches, so a later change that breaks it fails here first
Read this before adding a test and i recommend reading through CLAUDE.md
When contributing to this directory, please first discuss the change you wish to make via issue or pull request. We require screenshots and proof of your tests working on a live proxy.
Setup
The suites run against a live proxy, so bring one up first by running the litellm proxy locally. Point it at a config that prewires the example models the suites use (gpt-5.5, claude-haiku-4-5, gemini-2.5-flash, openai-text-embedding-3-small) with keys from your .env, and enables prompt storage, a redis cache, and the fast budget rescheduler the quota suites rely on. If your test needs another model, a pricing override, or a guardrail declared up front, add it to that config and read it back in the test rather than hardcoding values
Running the tests locally
-
Create a
.envfile in this directory with the provider keys the example models use, plus the master key and the Postgres/Redis coordinates your config reads back:LITELLM_MASTER_KEY="sk-1234" DATABASE_URL="postgresql://llmproxy:dbpassword9090@localhost:5432/litellm" REDIS_HOST="localhost" REDIS_PORT="6379" OPENAI_API_KEY="sk-..." ANTHROPIC_API_KEY="sk-..." GEMINI_API_KEY="..." -
Bring up a Postgres and a Redis for the proxy to use. The repo-root
docker-compose.ymlalready defines a Postgres on5432; adocker run -p 6379:6379 redis:7covers Redis. PointDATABASE_URL/REDIS_HOST/REDIS_PORTat whatever you run. Tests that read Redis directly default to the deployed shape (TLS + cluster mode) wheneverREDIS_HOSTis set, so for a local standalone Redis also setREDIS_CLUSTER=falseandREDIS_SSL=false(plusREDIS_PASSWORDwhen your Redis requires auth) -
Start the litellm proxy locally against your config and confirm it is live:
set -a && source .env && set +a litellm --config <your-e2e-config>.yml --port 4000 curl -fs http://localhost:4000/health/liveliness -
Run a suite against it; the harness reads
LITELLM_PROXY_URL(defaulthttp://localhost:4000):uv run pytest tests/e2e/llm_translation/ -vThe browser tests in the
management/suite drive the dashboard the proxy serves at/uithrough playwright, an optional dependency behindimportorskip(the suite's API tests run without it). It lives in thee2e-devdependency group; install it along with its browser:uv sync --inexact --group e2e-dev uv run playwright install chromiumThey also need a proxy whose bundled UI contains the change under test, so run the proxy from your branch (an editable install serves the UI your checkout builds)
Some suites need extra services the bare proxy does not start. The logging/ OTEL trace-completeness tests read spans back from a jaeger query API at http://localhost:16686 (override with E2E_OTEL_QUERY_URL); run a jaegertracing/all-in-one and point PHOENIX_COLLECTOR_HTTP_ENDPOINT at its OTLP ingest. The mcp/ suite needs the deterministic upstream MCP server in mcp_tests/mcp_e2e_upstream_server.py reachable by the proxy
Record and replay
E2E_FIXTURE_MODE=record runs a suite against the live proxy as usual while writing every request/response pair to a fixture bundle (default tests/e2e/.fixtures, override with E2E_FIXTURE_DIR); E2E_FIXTURE_MODE=replay then runs the same suite entirely from that bundle, with no proxy traffic and no provider spend; the proxy liveness gate is skipped, so replay runs with no proxy up at all. Unset (or live) behaves exactly as before the knob existed
E2E_FIXTURE_MODE=record uv run pytest tests/e2e/llm_translation/ -v
E2E_FIXTURE_MODE=replay uv run pytest tests/e2e/llm_translation/ -v
Replay fails hard (ReplayMiss) when the tests drift from the recording, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. See CLAUDE.md in this directory for the bundle format and the transport seam
Tests marked @pytest.mark.e2e hard-fail when no proxy answers /health/liveliness, so a run that goes red with No live proxy at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass
What a complete test looks like
A feature test is complete only when it walks the feature end to end, in this order
- CREATE the resource (key / team / budget / ...) and immediately queue its deletion
- CONFIGURE the feature's setting on it (assign the budget, turn on compression, set the limit)
- ACT; drive real traffic through the gateway exactly like prod does (right model, real auth headers, enough calls to actually trigger the behavior)
- SETTLE; poll the DB / spend logs until the write lands. Writes are eventually consistent (spend flushes on proxy_batch_write_at, ~60s), so poll to a deadline. Never sleep once
- ASSERT the recorded state the feature promises (spend > budget, cost reduced, tag attributed, ...)
- ASSERT the enforced behavior the gateway returns (429 budget_exceeded, block, refusal, ...)
- TEARDOWN; every resource you created is deleted
The one rule that makes it complete
It must assert BOTH sides: the recorded state (step 5) AND the enforced behavior (step 6)
A test that only checks "the call went through", or only checks spend without checking the 429, is not complete; it is checking plumbing, not the product promise
Example: budget enforcement
create a key -> (1)
assign a budget -> (2)
send a bunch of calls -> (3)
poll for db spend -> (4)
assert spend > budget -> (5)
assert status_code == 429 -> (6) ("budget_exceeded")
key auto-deleted on teardown -> (7)
The skeleton every test fills in
setup -> create the resource + queue cleanup
configure -> apply the feature's knob
act -> send real calls like production
settle -> poll the DB until the write lands
assert -> recorded state is correct (the feature happened)
assert -> gateway enforced it (the product promise held)
teardown -> delete everything you created
If a step is missing, the test is not done. That is the whole pattern
Style: lay the pattern down in a class
Keep the cases for one feature inside a class so the file reads as a spec for how that feature behaves in production. The class name says what is under test; each method is one behavior. Think of it as documenting the contract, with the rough intent being
# pseudo-code to convey intent
class TestPromptCompression:
def test_prompt_compression_add_to_virtual_key(self):
new_key = self.resources.create_key(user_id, compression=True) # turn the feature on
resources._defer(new_key) # queue key deletion
def test_prompt_compression_accumulate_spend(self, key_id, user_id):
for _ in range(10):
response = self.resources.proxy.post("gemini-2.5-flash", key_id, user_id)
compressed_value = ...
assert response.cost == compressed_value # the cost was actually reduced
That snippet only conveys intent. What you actually write uses the real harness: the client fixture for your suite, the scoped_key fixture for an auto-deleted key, typed pydantic bodies from models.py, and unwrap(...) on the tagged-union result. tests/e2e/llm_translation/test_custom_pricing_e2e.py is the reference to copy from; it creates a scoped key, drives a real gemini call, polls /spend/logs to a deadline for the cost-breakdown row, then asserts the input and output costs match the configured custom rates and that a sibling deployment kept its own price. Read it before writing yours
Use the shared transport; never touch requests directly
Every HTTP call goes through the shared transport, never through requests.* in a test. e2e_http.py is the only module permitted to call requests.*, and that is enforced in CI by tests/code_coverage_tests/check_e2e_no_raw_requests.py. A test that imports requests will fail the check
The shape is layered so tests stay declarative
transport.py exposes a Transport Protocol with post, get, delete, send, stream, probe, plus bearer(key) and the master header. HttpTransport fulfils it, and SplitTransport routes each call by path to the data plane or the control plane so a split control-plane/data-plane deployment works without any change in the test
proxy_client.py holds ProxyClient, a frozen dataclass that wraps a Transport and adds the operations tests reuse: generate_key / delete_key / key_info, model_info, the LLM calls chat / chat_stream / embed / ocr, the spend read-back spend_logs, and the poll helpers poll_logs_for_key / poll_logs_for_request_id that loop to poll_timeout instead of sleeping once. It is exposed as the session-scoped proxy fixture (see tests/e2e/conftest.py), which each suite's client fixture depends on and injects. Add a new route as a method here so other suites get it for free
Each suite provides its own client fixture (see llm_translation/passthrough_client.py), a frozen dataclass that holds the shared ProxyClient (as .proxy) and adds suite-specific routes. Cleanup runs through that same ProxyClient, so whatever keys or customers your test creates get torn down by the resources fixture
Request and response bodies are typed pydantic models in models.py; only the fields a test reads are modelled, and nothing passes raw dicts. Outcomes come back as a Result[R] tagged union (Success, NetworkError, UnauthorizedError, RateLimitedError, ValidationError, UnknownApiError). Handle them with match, or call unwrap(...) when a non-success should fail the test. The harness hard-fails and never skips: a test marked e2e fails when no proxy answers its liveness probe, and once a request reaches the proxy any wrong behavior is likewise a hard failure, so a missing proxy turns the run red instead of being mistaken for a pass
Mark live tests with @pytest.mark.e2e (on the class or the module). Pure coverage of the harness itself carries no marker and runs regardless. Use scoped_key for a fresh all-models key that auto-deletes, resources when you need to create and tear down more than a key, and unique_marker() from e2e_config to keep prompts, tags, and customer ids from colliding across concurrent runs and the shared response cache
Pre-commit steps
Before you push
-
Run
make lint-e2e-basedpyright(ormake checkwith your changes staged); the harness is fully typed and the gate allows zero basedpyright errors, enforced in CI on any PR touchingtests/e2e/**/*.py -
Add the models your test needs to the config your local proxy loads
-
Start the litellm proxy locally and run your suite against it:
litellm --config <your-e2e-config>.yml --port 4000 uv run pytest tests/e2e/<your_suite>/ -v -
Capture screenshots of the test run and attach them to the PR as proof
-
If a test fails because it surfaced a real issue in the product, flag that explicitly in the PR rather than reworking the test until it passes