mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
* test(e2e): add enum values, auto-discovering label gates and secret hiding for e2e metadata * test(e2e): tag a2a, access_control, other, secret_manager and migrations tests with Subject metadata * docs(e2e): name every markerless harness test file that carries no Subject * test(e2e): keep the step discovery comprehensions to one for clause
176 lines
7.2 KiB
Python
176 lines
7.2 KiB
Python
"""Client for the `other` holding-pen suite: the auth gate (master key vs an
|
|
invalid key on an admin route), JWT auth against the suite's Keycloak realm
|
|
(idp.py), and the process-lifecycle health probes (liveness, public readiness,
|
|
authenticated readiness diagnostics).
|
|
|
|
Holds the shared ProxyClient so `resources` / `scoped_key` still clean up, and
|
|
adds only the routes these behaviors need. The health probes deliberately send
|
|
no auth header (public routes), so they go through the transport with an empty
|
|
headers model rather than a bearer. JWT tests reach the identity provider
|
|
through `idp`, which provisions identities and mints tokens through Keycloak's
|
|
own endpoints, so no test ever holds a signing key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
from e2e_http import AnthropicHeaders, AuthHeaders, NoBody, ProbeResult, Result
|
|
from e2e_metadata import step
|
|
from idp import Keycloak, keycloak_from_env
|
|
from models import (
|
|
ChatBody,
|
|
ChatResponse,
|
|
JwtKeyMappingDeleteBody,
|
|
JwtKeyMappingDeleteResponse,
|
|
JwtKeyMappingListParams,
|
|
JwtKeyMappingListResponse,
|
|
ModelsListParams,
|
|
ModelsListResponse,
|
|
ReadinessDetailsResponse,
|
|
ReadinessResponse,
|
|
UserInfoParams,
|
|
UserInfoWithKeysResponse,
|
|
UserListParams,
|
|
UserListResponse,
|
|
UserNewBody,
|
|
UserNewResponse,
|
|
)
|
|
from proxy_client import ProxyClient
|
|
from pydantic import Field
|
|
|
|
|
|
class TeamHeaders(AuthHeaders):
|
|
"""Bearer auth plus ``x-litellm-team-id``, the header a JWT caller sends to
|
|
pick one of the teams it belongs to."""
|
|
|
|
x_litellm_team_id: str = Field(serialization_alias="x-litellm-team-id")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OtherClient:
|
|
proxy: ProxyClient
|
|
|
|
@property
|
|
def idp(self) -> Keycloak:
|
|
"""Resolved per use, so the suite's non-JWT tests never need the IdP env."""
|
|
return keycloak_from_env()
|
|
|
|
@step("Call /health/liveliness without credentials")
|
|
def liveness(self) -> ProbeResult:
|
|
"""GET /health/liveliness. Unauthenticated; the probe returns status +
|
|
raw body so the test can assert the worker reports itself alive."""
|
|
return self.proxy.transport.probe("/health/liveliness", params=NoBody())
|
|
|
|
@step("Call /health/readiness without credentials")
|
|
def readiness_public(self) -> Result[ReadinessResponse]:
|
|
"""GET /health/readiness with no credential at all, proving the probe is
|
|
safe to expose to an unauthenticated load balancer."""
|
|
return self.proxy.transport.get(
|
|
"/health/readiness",
|
|
headers=NoBody(),
|
|
params=NoBody(),
|
|
response_type=ReadinessResponse,
|
|
)
|
|
|
|
@step("Call /health/readiness/details with the given key")
|
|
def readiness_details(self, key: str) -> Result[ReadinessDetailsResponse]:
|
|
return self.proxy.transport.get(
|
|
"/health/readiness/details",
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=NoBody(),
|
|
response_type=ReadinessDetailsResponse,
|
|
)
|
|
|
|
@step("Call /health/readiness/details without credentials")
|
|
def readiness_details_unauthenticated(self) -> Result[ReadinessDetailsResponse]:
|
|
return self.proxy.transport.get(
|
|
"/health/readiness/details",
|
|
headers=NoBody(),
|
|
params=NoBody(),
|
|
response_type=ReadinessDetailsResponse,
|
|
)
|
|
|
|
@step("Create the {body.user_role} user {body.user_email} through /user/new")
|
|
def user_new(self, body: UserNewBody) -> Result[UserNewResponse]:
|
|
"""POST /user/new under the master key: seed the litellm user a JWT
|
|
`sub` claim resolves to, before that token ever reaches the proxy."""
|
|
return self.proxy.transport.post(
|
|
"/user/new",
|
|
headers=self.proxy.transport.master,
|
|
json=body,
|
|
response_type=UserNewResponse,
|
|
)
|
|
|
|
@step("Read the user's keys from /user/info")
|
|
def user_info(self, user_id: str) -> Result[UserInfoWithKeysResponse]:
|
|
"""GET /user/info under the master key. Only the user's key rows are
|
|
modelled: `token` is the stored key hash, never the plaintext key."""
|
|
return self.proxy.transport.get(
|
|
"/user/info",
|
|
headers=self.proxy.transport.master,
|
|
params=UserInfoParams(user_id=user_id),
|
|
response_type=UserInfoWithKeysResponse,
|
|
)
|
|
|
|
@step("List the JWT-to-key mappings from /jwt/key/mapping/list")
|
|
def jwt_mapping_list(self) -> Result[JwtKeyMappingListResponse]:
|
|
"""GET /jwt/key/mapping/list under the master key."""
|
|
return self.proxy.transport.get(
|
|
"/jwt/key/mapping/list",
|
|
headers=self.proxy.transport.master,
|
|
params=JwtKeyMappingListParams(size=100),
|
|
response_type=JwtKeyMappingListResponse,
|
|
)
|
|
|
|
@step("Delete the JWT-to-key mapping")
|
|
def jwt_mapping_delete(self, mapping_id: str) -> Result[JwtKeyMappingDeleteResponse]:
|
|
"""POST /jwt/key/mapping/delete under the master key."""
|
|
return self.proxy.transport.post(
|
|
"/jwt/key/mapping/delete",
|
|
headers=self.proxy.transport.master,
|
|
json=JwtKeyMappingDeleteBody(id=mapping_id),
|
|
response_type=JwtKeyMappingDeleteResponse,
|
|
)
|
|
|
|
@step("Send a /chat/completions request to {body.model} as team {team} with the given token")
|
|
def chat_as_team(self, token: str, team: str, body: ChatBody) -> Result[ChatResponse]:
|
|
"""POST /chat/completions under `token` with `x-litellm-team-id: team`."""
|
|
return self.proxy.transport.post(
|
|
"/chat/completions",
|
|
headers=TeamHeaders(
|
|
authorization=self.proxy.transport.bearer(token).authorization,
|
|
x_litellm_team_id=team,
|
|
),
|
|
json=body,
|
|
response_type=ChatResponse,
|
|
)
|
|
|
|
@step("List the models from /v1/models with the given token, in the Anthropic shape: {anthropic}")
|
|
def list_models_as(self, token: str, *, anthropic: bool = False) -> Result[ModelsListResponse]:
|
|
"""GET /v1/models under `token`, in the OpenAI shape or, with `anthropic`, the
|
|
Anthropic Models API shape Claude Code reads. Both carry `data[].id`."""
|
|
bearer: Final = self.proxy.transport.bearer(token)
|
|
return self.proxy.transport.get(
|
|
"/v1/models",
|
|
headers=AnthropicHeaders(authorization=bearer.authorization) if anthropic else bearer,
|
|
params=ModelsListParams(return_wildcard_routes=False),
|
|
response_type=ModelsListResponse,
|
|
)
|
|
|
|
@step("List users from /user/list with the given key")
|
|
def list_users_as(self, key: str) -> Result[UserListResponse]:
|
|
"""GET /user/list under `key`. Admin-only, so it doubles as the master
|
|
key's authorization proof: the master key (proxy admin) reads it, a
|
|
non-matching key is rejected before it ever reaches the handler."""
|
|
return self.proxy.transport.get(
|
|
"/user/list",
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=UserListParams(user_ids="e2e-test-user"),
|
|
response_type=UserListResponse,
|
|
)
|
|
|
|
|
|
def build_client(proxy: ProxyClient) -> OtherClient:
|
|
return OtherClient(proxy=proxy)
|