-
-
Notifications
You must be signed in to change notification settings - Fork 10.8k
test(e2e): migrate access-control and inference-endpoint regression tests #32016
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mubashir1osmani
merged 4 commits into
litellm_internal_staging
from
litellm_e2e_endpoint_access_control_tests
Jul 5, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
4a78327
test(e2e): migrate access-control and inference-endpoint regression t…
mubashir1osmani 99a5e27
Update endpoints_client.py
mubashir1osmani a0c9e10
test(e2e): provision custom-pricing deployments via /model/new
mubashir1osmani a33e45f
Update tests/e2e/llm_translation/endpoints_client.py
mubashir1osmani File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| 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()) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.