test(e2e): move rust OCR e2e into llm_translation on the shared harness

The rust OCR smoke lived under tests/e2e/gateway and spoke raw httpx with
ad-hoc dataclasses, diverging from the rest of tests/e2e. Move it to
tests/e2e/llm_translation and rebuild it on the shared harness: typed pydantic
bodies in models.py (OcrDocument/OcrBody/OcrPage/OcrResponse), a Gateway.ocr()
route through the shared transport, Result/unwrap for outcomes, the e2e marker,
and the client/scoped_key fixtures. No test touches httpx or requests directly
now.

Behavior preserved: the config-presence check still reads gateway/litellm-config.yml
without a proxy, /model/info confirms the proxy loaded every rust-ocr deployment,
and each provider case asserts a well-formed OCR document over /v1/ocr.

Also add tests/e2e/CONTRIBUTING.md documenting the end-to-end testing flow so new
features land with coverage that walks the feature like production does.
This commit is contained in:
mubashir1osmani 2026-06-26 19:21:55 -07:00
parent 01efcc1b74
commit 4d6fc36fa0
5 changed files with 254 additions and 161 deletions

94
tests/e2e/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,94 @@
# Contributing e2e tests
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. The harness already encodes most of the rules; your job is to fill in the feature-specific behavior, not to reinvent plumbing
## What a complete test looks like
A feature test is complete only when it walks the feature end to end, in this order
1. CREATE the resource (key / team / budget / ...) and immediately queue its deletion
2. CONFIGURE the feature's setting on it (assign the budget, turn on compression, set the limit)
3. ACT; drive real traffic through the gateway exactly like prod does (right model, real auth headers, enough calls to actually trigger the behavior)
4. 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
5. ASSERT the recorded state the feature promises (spend > budget, cost reduced, tag attributed, ...)
6. ASSERT the enforced behavior the gateway returns (429 budget_exceeded, block, refusal, ...)
7. 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
```python
# pseudo-code to convey intent, not the real API
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.gateway.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
`e2e_gateway.py` holds `Gateway`, 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. 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 `Gateway` and adds suite-specific routes. Cleanup runs through that same `Gateway`, 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 skip-vs-fail split is deliberate: a test marked `e2e` skips when no proxy answers its liveness probe, but once a request reaches the proxy any wrong behavior is a hard failure, never a skip
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 basedpyright over your changes; the harness is fully typed and new code must not add `Any` or widen the budgets
- Bring the stack up with docker-compose from this directory and run your suite locally against it, so you exercise the same skip-vs-fail path CI does
- Use the config at `tests/e2e/gateway/litellm-config.yml` if your feature needs a model, pricing override, guardrail, or other proxy setting declared up front; add the deployment there and read it back in the test rather than hardcoding values
- Capture screenshots of the tests passing and attach them to the PR as proof of fix

View file

@ -34,6 +34,8 @@ from models import (
KeyInfoResponse,
ModelInfoEntry,
ModelInfoResponse,
OcrBody,
OcrResponse,
SpendLogRow,
SpendLogs,
SpendLogsParams,
@ -120,9 +122,7 @@ class Gateway:
)
def chat_stream(self, key: str, body: ChatBody) -> StreamingResponse:
return self.transport.stream(
"/chat/completions", headers=self.transport.bearer(key), json=body
)
return self.transport.stream("/chat/completions", headers=self.transport.bearer(key), json=body)
def embed(self, key: str, body: EmbedBody) -> Result[EmbedResponse]:
return self.transport.post(
@ -132,6 +132,14 @@ class Gateway:
response_type=EmbedResponse,
)
def ocr(self, key: str, body: OcrBody) -> Result[OcrResponse]:
return self.transport.post(
"/v1/ocr",
headers=self.transport.bearer(key),
json=body,
response_type=OcrResponse,
)
# ---- spend read-back ------------------------------------------------
def spend_logs(self, params: SpendLogsParams) -> list[SpendLogRow]:
@ -150,9 +158,7 @@ class Gateway:
def poll_logs_for_key(
self, key: str, *, min_rows: int = 1, predicate: RowsPredicate | None = None
) -> list[SpendLogRow]:
return self._poll(
lambda: self.spend_logs(SpendLogsParams(api_key=key)), min_rows, predicate
)
return self._poll(lambda: self.spend_logs(SpendLogsParams(api_key=key)), min_rows, predicate)
def poll_logs_for_request_id(
self,

View file

@ -1,148 +0,0 @@
"""
Gateway E2E smoke for Rust-backed OCR.
Start the proxy with:
LITELLM_USE_RUST_OCR=1 litellm --config tests/e2e/gateway/litellm-config.yml --port 4000
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import pytest
import yaml
TEST_PDF_URL = (
"https://cdn.jsdelivr.net/gh/BerriAI/litellm"
"@d769e81c90d453240c61fc572cdb27fae06a89d0"
"/tests/llm_translation/fixtures/dummy.pdf"
)
TEST_IMAGE_URL = (
"https://cdn.jsdelivr.net/gh/BerriAI/litellm"
"@d769e81c90d453240c61fc572cdb27fae06a89d0"
"/tests/image_gen_tests/test_image.png"
)
RUST_OCR_GATEWAY_CASES = [
pytest.param(
"rust-ocr-mistral",
{"type": "document_url", "document_url": TEST_PDF_URL},
id="mistral",
),
pytest.param(
"rust-ocr-azure-ai",
{"type": "document_url", "document_url": TEST_PDF_URL},
id="azure_ai",
),
pytest.param(
"rust-ocr-azure-document-intelligence",
{"type": "document_url", "document_url": TEST_PDF_URL},
id="azure_document_intelligence",
),
pytest.param(
"rust-ocr-vertex-mistral",
{"type": "document_url", "document_url": TEST_PDF_URL},
id="vertex_mistral",
),
pytest.param(
"rust-ocr-vertex-deepseek",
{
"type": "image_url",
"image_url": os.getenv("RUST_OCR_IMAGE_URL", TEST_IMAGE_URL),
},
id="vertex_deepseek",
),
]
CONFIG_PATH = Path(__file__).with_name("litellm-config.yml")
@dataclass(frozen=True)
class OcrGateway:
base_url: str
master_key: str
def model_names(self) -> set[str]:
with httpx.Client(
timeout=float(os.getenv("E2E_REQUEST_TIMEOUT", "120"))
) as client:
response = client.get(
f"{self.base_url.rstrip('/')}/model/info",
headers={"Authorization": f"Bearer {self.master_key}"},
)
assert response.status_code == 200, response.text
return {
model["model_name"]
for model in response.json().get("data", [])
if "model_name" in model
}
def ocr(self, model: str, document: dict[str, str]) -> httpx.Response:
with httpx.Client(
timeout=float(os.getenv("E2E_REQUEST_TIMEOUT", "120"))
) as client:
return client.post(
f"{self.base_url.rstrip('/')}/v1/ocr",
headers={"Authorization": f"Bearer {self.master_key}"},
json={"model": model, "document": document},
)
@dataclass(frozen=True)
class OcrResources:
gateway: OcrGateway
@pytest.fixture
def resources() -> OcrResources:
proxy_url = os.getenv("LITELLM_PROXY_URL")
if not proxy_url:
pytest.skip(
"Start a Rust OCR proxy and set LITELLM_PROXY_URL, e.g. http://localhost:4000"
)
return OcrResources(
gateway=OcrGateway(
base_url=proxy_url,
master_key=os.getenv("LITELLM_MASTER_KEY", "sk-1234"),
)
)
def _assert_ocr_response_shape(response_json: dict[str, Any]) -> None:
assert response_json["object"] == "ocr"
assert response_json["model"]
assert isinstance(response_json["pages"], list)
assert len(response_json["pages"]) > 0
assert "index" in response_json["pages"][0]
assert "markdown" in response_json["pages"][0]
class TestRustOcrGateway:
def test_rust_ocr_models_are_on_gateway_config(self) -> None:
config = yaml.safe_load(CONFIG_PATH.read_text())
configured_models = {
model_config["model_name"] for model_config in config["model_list"]
}
expected_models = {case.values[0] for case in RUST_OCR_GATEWAY_CASES}
assert expected_models.issubset(configured_models)
def test_running_gateway_loaded_rust_ocr_models(
self, resources: OcrResources
) -> None:
expected_models = {case.values[0] for case in RUST_OCR_GATEWAY_CASES}
assert expected_models.issubset(resources.gateway.model_names())
@pytest.mark.parametrize(("model", "document"), RUST_OCR_GATEWAY_CASES)
def test_rust_ocr_model_gateway_response(
self, resources: OcrResources, model: str, document: dict[str, str]
) -> None:
response = resources.gateway.ocr(model, document)
assert response.status_code == 200, response.text
_assert_ocr_response_shape(response.json())

View file

@ -0,0 +1,117 @@
"""Live e2e: Rust-backed OCR is reachable through the gateway across providers.
The gateway config declares one rust-ocr deployment per provider (mistral,
azure_ai, azure document intelligence, vertex mistral, vertex deepseek). Start the
proxy with the Rust OCR path enabled:
LITELLM_USE_RUST_OCR=1 litellm --config tests/e2e/gateway/litellm-config.yml
Three behaviors are checked: the config declares every provider's deployment (a
pure config read, no proxy needed); the running proxy loaded them onto /model/info;
and each one returns a well-formed OCR document over /v1/ocr. Per the e2e
"skip on environment, fail on behavior" rule, the proxy-backed cases skip when no
proxy answers but fail (never skip) once a request reaches it, so a provider whose
credentials are missing surfaces as a hard failure rather than silent green.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
import yaml
from pydantic import BaseModel
from e2e_http import unwrap
from models import OcrBody, OcrDocument, OcrResponse
from passthrough_client import PassthroughClient
# Tiny in-repo fixtures served via jsdelivr (sha-pinned, immutable) so the request
# bodies stay stable across runs.
TEST_PDF_URL = (
"https://cdn.jsdelivr.net/gh/BerriAI/litellm"
"@d769e81c90d453240c61fc572cdb27fae06a89d0"
"/tests/llm_translation/fixtures/dummy.pdf"
)
TEST_IMAGE_URL = (
"https://cdn.jsdelivr.net/gh/BerriAI/litellm"
"@d769e81c90d453240c61fc572cdb27fae06a89d0"
"/tests/image_gen_tests/test_image.png"
)
CONFIG_PATH = Path(__file__).resolve().parents[1] / "gateway" / "litellm-config.yml"
@dataclass(frozen=True, slots=True)
class _OcrCase:
model: str
document: OcrDocument
RUST_OCR_CASES: tuple[_OcrCase, ...] = (
_OcrCase(
"rust-ocr-mistral",
OcrDocument(type="document_url", document_url=TEST_PDF_URL),
),
_OcrCase(
"rust-ocr-azure-ai",
OcrDocument(type="document_url", document_url=TEST_PDF_URL),
),
_OcrCase(
"rust-ocr-azure-document-intelligence",
OcrDocument(type="document_url", document_url=TEST_PDF_URL),
),
_OcrCase(
"rust-ocr-vertex-mistral",
OcrDocument(type="document_url", document_url=TEST_PDF_URL),
),
_OcrCase(
"rust-ocr-vertex-deepseek",
OcrDocument(type="image_url", image_url=TEST_IMAGE_URL),
),
)
_EXPECTED_MODELS = frozenset(case.model for case in RUST_OCR_CASES)
_CASE_IDS = tuple(case.model.removeprefix("rust-ocr-") for case in RUST_OCR_CASES)
class _ConfiguredModel(BaseModel):
model_name: str
class _GatewayConfig(BaseModel):
model_list: list[_ConfiguredModel]
def _configured_model_names() -> frozenset[str]:
config = _GatewayConfig.model_validate(yaml.safe_load(CONFIG_PATH.read_text()))
return frozenset(entry.model_name for entry in config.model_list)
def _assert_ocr_document(response: OcrResponse) -> None:
assert response.object == "ocr", f"expected object='ocr', got {response.object!r}"
assert response.model, "response missing the resolved model name"
assert response.pages, "OCR returned no pages"
assert response.pages[0].markdown is not None, "first page has no markdown"
def test_rust_ocr_models_declared_in_gateway_config() -> None:
"""Pure config read (no proxy): every provider's rust-ocr deployment the suite
exercises is declared in the gateway config the proxy runs with. A case added
here without a matching deployment fails before any live call is attempted."""
missing = _EXPECTED_MODELS - _configured_model_names()
assert not missing, f"rust-ocr models absent from {CONFIG_PATH.name}: {missing}"
@pytest.mark.e2e
class TestRustOcrGateway:
def test_gateway_loaded_rust_ocr_models(self, client: PassthroughClient) -> None:
loaded = frozenset(entry.model_name for entry in client.gateway.model_info())
missing = _EXPECTED_MODELS - loaded
assert not missing, f"proxy did not load rust-ocr models: {missing}"
@pytest.mark.parametrize("case", RUST_OCR_CASES, ids=_CASE_IDS)
def test_rust_ocr_response(self, client: PassthroughClient, scoped_key: str, case: _OcrCase) -> None:
response = unwrap(client.gateway.ocr(scoped_key, OcrBody(model=case.model, document=case.document)))
_assert_ocr_document(response)

View file

@ -125,6 +125,34 @@ class EmbedResponse(BaseModel):
model: str | None = None
# ---------- ocr ----------
class OcrDocument(BaseModel):
"""A document for /v1/ocr in Mistral OCR format: a document_url for PDFs/docs
or an image_url for images. exclude_none on serialize drops the unset one."""
type: str
document_url: str | None = None
image_url: str | None = None
class OcrBody(BaseModel):
model: str
document: OcrDocument
class OcrPage(BaseModel):
index: int
markdown: str
class OcrResponse(BaseModel):
object: str | None = None
model: str | None = None
pages: list[OcrPage] = []
# ---------- spend logs ----------
@ -216,14 +244,10 @@ class CustomPricing(BaseModel):
def token_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
"""Spend for a fresh (uncached) call under these rates: the proxy's
custom-pricing formula (prompt * input + completion * output)."""
assert (
self.input_cost_per_token is not None
and self.output_cost_per_token is not None
), "custom pricing has no per-token rates"
return (
prompt_tokens * self.input_cost_per_token
+ completion_tokens * self.output_cost_per_token
assert self.input_cost_per_token is not None and self.output_cost_per_token is not None, (
"custom pricing has no per-token rates"
)
return prompt_tokens * self.input_cost_per_token + completion_tokens * self.output_cost_per_token
class ModelInfoEntry(BaseModel):