Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion tests/e2e/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Code-style rules for writing tests under `tests/e2e/`. The harness already encod

Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family or behavior area. If you add a new folder, you must add a line here describing what kind of tests belong in it, so the layout stays self-describing. `gateway/` is the exception: it holds proxy configuration only and never tests

- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR
- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
- `access_control/` - the gateway's authorization and error-shape contract: per-key model allow-lists, route-group permissions (`allowed_routes`), and unknown-model validation
- `embeddings/` - the `/embeddings` endpoint across providers
- `batches/` - the `/batches` endpoint (placeholder until the first test lands)
- `realtime/` - realtime websocket sessions, including the pipecat audio path
Expand Down
56 changes: 56 additions & 0 deletions tests/e2e/access_control/access_control_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Client for the access-control e2e suite."""

from __future__ import annotations

from dataclasses import dataclass

from e2e_gateway import Gateway, build_gateway
from e2e_http import StreamingResponse
from models import (
ChatBody,
ChatMessage,
KeyGenerateBody,
LiteLLMParamsBody,
ModelInfoBody,
ModelNewBody,
)

MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied"
ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"


@dataclass(frozen=True, slots=True)
class AccessControlClient:
gateway: Gateway

def llm_only_key(self) -> str:
return self.gateway.generate_key(
KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])
)

def delete_key(self, key: str) -> None:
self.gateway.delete_key(key)

def chat_status(self, key: str, model: str, content: str) -> StreamingResponse:
return self.gateway.transport.send(
"/chat/completions",
headers=self.gateway.transport.bearer(key),
json=ChatBody(
model=model, messages=[ChatMessage(role="user", content=content)]
),
)

def create_model_status(self, key: str, model_name: str) -> StreamingResponse:
return self.gateway.transport.send(
"/model/new",
headers=self.gateway.transport.bearer(key),
json=ModelNewBody(
model_name=model_name,
litellm_params=LiteLLMParamsBody(model="openai/gpt-4o-mini"),
model_info=ModelInfoBody(id=model_name),
),
)


def build_client() -> AccessControlClient:
return AccessControlClient(gateway=build_gateway())
10 changes: 10 additions & 0 deletions tests/e2e/access_control/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Access-control suite client fixture; lifecycle/skip/marker live in the parent conftest."""

import pytest

from access_control_client import AccessControlClient, build_client


@pytest.fixture(scope="session")
def client() -> AccessControlClient:
return build_client()
83 changes: 83 additions & 0 deletions tests/e2e/access_control/test_access_control_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Live e2e: the gateway's authorization and error-shape contract.

A virtual key may only call models in its allow-list and route groups in its
allowed_routes; both denials are a 403 raised before any provider is touched. A
syntactically valid request naming a non-existent model is a 400 with a JSON body,
never forwarded and never a 5xx. Migrated from
litellm-regression-tests/tests/test_access_control.py: the source asserted 401 for
the disallowed-model case against an older proxy, but the current contract
(auth_checks.py) is a 403 key_model_access_denied, and the unknown-route check is
replaced by a stronger route-permission check (an llm-only key rejected from a
management route).
"""

from __future__ import annotations

import json

import pytest

from access_control_client import (
AccessControlClient,
MODEL_ACCESS_DENIED_MARKER,
ROUTE_NOT_ALLOWED_MARKER,
)
from e2e_config import unique_marker
from lifecycle import ResourceManager

pytestmark = pytest.mark.e2e

ALLOWED_MODEL = "gemini-2.5-flash"
DISALLOWED_MODEL = "gpt-5.5"


def _is_json(body: str) -> bool:
try:
json.loads(body)
return True
except ValueError:
return False


class TestAccessControl:
def test_disallowed_model_is_denied_403(
self, client: AccessControlClient, resources: ResourceManager
) -> None:
key = resources.key(models=[ALLOWED_MODEL])
result = client.chat_status(
key, DISALLOWED_MODEL, f"capital of France? {unique_marker()}"
)
assert result.status_code == 403, (
f"key limited to {ALLOWED_MODEL!r} calling {DISALLOWED_MODEL!r} must be "
f"denied 403, got {result.status_code}: {result.body[:300]}"
)
assert MODEL_ACCESS_DENIED_MARKER in result.body, (
f"403 body must be a model-access denial, got: {result.body[:300]}"
)

def test_llm_only_key_forbidden_from_management_route_403(
self, client: AccessControlClient, resources: ResourceManager
) -> None:
key = client.llm_only_key()
resources.defer(lambda: client.delete_key(key))
result = client.create_model_status(key, f"e2e-forbidden-{unique_marker()}")
assert result.status_code == 403, (
f"llm-only key calling a management route must be denied 403, got "
f"{result.status_code}: {result.body[:300]}"
)
assert ROUTE_NOT_ALLOWED_MARKER in result.body, (
f"403 body must be a route-permission denial, got: {result.body[:300]}"
)

def test_unknown_model_returns_400(
self, client: AccessControlClient, resources: ResourceManager
) -> None:
key = resources.key()
result = client.chat_status(
key, f"nonexistent-model-{unique_marker()}", "hi this is a test"
)
assert result.status_code == 400, (
f"unknown model must be rejected 400 before forwarding, got "
f"{result.status_code}: {result.body[:300]}"
)
assert _is_json(result.body), f"400 body must be valid JSON: {result.body[:300]}"
6 changes: 6 additions & 0 deletions tests/e2e/llm_translation/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@

import pytest

from endpoints_client import EndpointsClient, build_endpoints_client
from passthrough_client import PassthroughClient, build_client


@pytest.fixture(scope="session")
def client() -> PassthroughClient:
return build_client()


@pytest.fixture(scope="session")
def endpoints_client() -> EndpointsClient:
return build_endpoints_client()
219 changes: 219 additions & 0 deletions tests/e2e/llm_translation/endpoints_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Client for the non-chat inference endpoints (responses, messages, rerank,
embeddings, audio speech, image generation).

Each test registers the deployment it needs through /model/new (deleted on
teardown), so nothing is hardcoded into the gateway config, then drives the
endpoint with `send` and parses the provider-native body with a suite-local model
so the assertion is on real content, not just a 200.
"""

from __future__ import annotations

from dataclasses import dataclass

from pydantic import BaseModel

from e2e_gateway import Gateway, build_gateway
from e2e_http import NoBody, StreamingResponse, is_ok, unwrap
from models import (
ChatMessage,
LiteLLMParamsBody,
ModelDeleteBody,
ModelInfoBody,
ModelNewBody,
ModelNewResponse,
)


class ResponsesRequest(BaseModel):
model: str
input: str
instructions: str | None = None


class MessagesRequest(BaseModel):
model: str
max_tokens: int
messages: list[ChatMessage]


class EmbeddingsRequest(BaseModel):
model: str
input: str


class RerankRequest(BaseModel):
model: str
query: str
documents: list[str]
top_n: int


class SpeechRequest(BaseModel):
model: str
input: str
voice: str


class ImageRequest(BaseModel):
model: str
prompt: str
n: int = 1
size: str = "1024x1024"


class ResponsesOutputContent(BaseModel):
type: str | None = None
text: str | None = None


class ResponsesOutputItem(BaseModel):
type: str | None = None
content: list[ResponsesOutputContent] = []


class ResponsesResult(BaseModel):
id: str | None = None
status: str | None = None
model: str | None = None
output: list[ResponsesOutputItem] = []

@property
def text(self) -> str:
return "".join(
content.text or "" for item in self.output for content in item.content
)


class AnthropicContentBlock(BaseModel):
type: str | None = None
text: str | None = None


class MessagesResult(BaseModel):
id: str | None = None
role: str | None = None
model: str | None = None
content: list[AnthropicContentBlock] = []

@property
def text(self) -> str:
return "".join(block.text or "" for block in self.content)


class EmbeddingItem(BaseModel):
embedding: list[float] = []


class EmbeddingsResult(BaseModel):
data: list[EmbeddingItem] = []

@property
def first_vector(self) -> tuple[float, ...]:
return tuple(self.data[0].embedding) if self.data else ()


class RerankItem(BaseModel):
index: int | None = None
relevance_score: float | None = None


class RerankResult(BaseModel):
results: list[RerankItem] = []


class ImageItem(BaseModel):
url: str | None = None
b64_json: str | None = None


class ImagesResult(BaseModel):
data: list[ImageItem] = []


@dataclass(frozen=True, slots=True)
class EndpointsClient:
gateway: Gateway

def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str:
"""Register a deployment under `model_name` (id == model_name) and return the
model_id. add_deployment runs synchronously in /model/new, so the model is
callable as soon as this returns."""
return unwrap(
self.gateway.transport.post(
"/model/new",
headers=self.gateway.transport.master,
json=ModelNewBody(
model_name=model_name,
litellm_params=litellm_params,
model_info=ModelInfoBody(id=model_name),
),
response_type=ModelNewResponse,
)
).model_id

def delete_model(self, model_id: str) -> None:
result = self.gateway.transport.post(
"/model/delete",
headers=self.gateway.transport.master,
json=ModelDeleteBody(id=model_id),
response_type=NoBody,
)
Comment thread
mubashir1osmani marked this conversation as resolved.
if not is_ok(result):
import warnings
warnings.warn(f"delete_model({model_id!r}) failed: {result}", stacklevel=2)

def _send(self, path: str, key: str, body: BaseModel) -> StreamingResponse:
return self.gateway.transport.send(
path, headers=self.gateway.transport.bearer(key), json=body
)

def responses(self, key: str, model: str, text: str) -> StreamingResponse:
return self._send(
"/v1/responses",
key,
ResponsesRequest(
model=model, input=text, instructions="You are a helpful assistant"
),
)

def messages(
self, key: str, model: str, text: str, *, max_tokens: int = 64
) -> StreamingResponse:
return self._send(
"/v1/messages",
key,
MessagesRequest(
model=model,
max_tokens=max_tokens,
messages=[ChatMessage(role="user", content=text)],
),
)

def embeddings(self, key: str, model: str, text: str) -> StreamingResponse:
return self._send("/embeddings", key, EmbeddingsRequest(model=model, input=text))

def rerank(
self, key: str, model: str, query: str, documents: list[str], top_n: int
) -> StreamingResponse:
return self._send(
"/v1/rerank",
key,
RerankRequest(model=model, query=query, documents=documents, top_n=top_n),
)

def audio_speech(
self, key: str, model: str, text: str, *, voice: str = "alloy"
) -> StreamingResponse:
return self._send(
"/v1/audio/speech", key, SpeechRequest(model=model, input=text, voice=voice)
)

def images(self, key: str, model: str, prompt: str) -> StreamingResponse:
return self._send(
"/v1/images/generations", key, ImageRequest(model=model, prompt=prompt)
)


def build_endpoints_client() -> EndpointsClient:
return EndpointsClient(gateway=build_gateway())
Loading
Loading