mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* fix(auth): resolve bare model names against wildcard deployments in model access groups * test(e2e): cover model access group permission checks on keys and teams
152 lines
4.9 KiB
Python
152 lines
4.9 KiB
Python
"""Client for the access-control e2e suite."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from proxy_client import ProxyClient
|
|
from e2e_http import NoBody, StreamingResponse, is_ok, unwrap
|
|
from models import (
|
|
ChatBody,
|
|
ChatMessage,
|
|
KeyGenerateBody,
|
|
LiteLLMParamsBody,
|
|
ModelInfoBody,
|
|
ModelNewBody,
|
|
TeamDeleteBody,
|
|
TeamInfoParams,
|
|
TeamInfoResponse,
|
|
TeamNewBody,
|
|
TeamNewResponse,
|
|
TeamUpdateBody,
|
|
)
|
|
|
|
MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied"
|
|
TEAM_MODEL_ACCESS_DENIED_MARKER = "team_model_access_denied"
|
|
ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"
|
|
|
|
|
|
class ApiErrorDetail(BaseModel):
|
|
message: str | None = None
|
|
type: str | None = None
|
|
code: str | int | None = None
|
|
|
|
|
|
class ApiErrorEnvelope(BaseModel):
|
|
error: ApiErrorDetail
|
|
|
|
|
|
class AccessGroupInfoResponse(BaseModel):
|
|
"""GET /access_group/{name}/info: the deployments a model access group grants."""
|
|
|
|
access_group: str
|
|
model_names: list[str]
|
|
deployment_count: int
|
|
|
|
|
|
def error_envelope(body: str) -> ApiErrorEnvelope | None:
|
|
"""The OpenAI-shaped `{"error": {...}}` a client parses, or None if absent."""
|
|
try:
|
|
return ApiErrorEnvelope.model_validate_json(body)
|
|
except ValidationError:
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AccessControlClient:
|
|
proxy: ProxyClient
|
|
|
|
def llm_only_key(self) -> str:
|
|
return self.proxy.generate_key(
|
|
KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])
|
|
)
|
|
|
|
def delete_key(self, key: str) -> None:
|
|
self.proxy.delete_key(key)
|
|
|
|
def chat_status(
|
|
self, key: str, model: str, content: str, max_completion_tokens: int | None = None
|
|
) -> StreamingResponse:
|
|
return self.proxy.transport.send(
|
|
"/chat/completions",
|
|
headers=self.proxy.transport.bearer(key),
|
|
json=ChatBody(
|
|
model=model,
|
|
messages=[ChatMessage(role="user", content=content)],
|
|
max_completion_tokens=max_completion_tokens,
|
|
),
|
|
)
|
|
|
|
def create_team(self, team_alias: str, models: list[str]) -> str:
|
|
team_id = unwrap(
|
|
self.proxy.transport.post(
|
|
"/team/new",
|
|
headers=self.proxy.transport.master,
|
|
json=TeamNewBody(team_alias=team_alias, models=models),
|
|
response_type=TeamNewResponse,
|
|
)
|
|
).team_id
|
|
self._await_team(team_id)
|
|
return team_id
|
|
|
|
def set_team_models(self, team_id: str, team_alias: str, models: list[str]) -> None:
|
|
"""Replace the team's allow-list. /model/new appends a team-scoped deployment's
|
|
public name to it, so a test that means to grant only an access group has to
|
|
put the allow-list back afterwards."""
|
|
_ = unwrap(
|
|
self.proxy.transport.post(
|
|
"/team/update",
|
|
headers=self.proxy.transport.master,
|
|
json=TeamUpdateBody(team_id=team_id, team_alias=team_alias, models=models),
|
|
response_type=NoBody,
|
|
)
|
|
)
|
|
|
|
def delete_team(self, team_id: str) -> None:
|
|
_ = self.proxy.transport.post(
|
|
"/team/delete",
|
|
headers=self.proxy.transport.master,
|
|
json=TeamDeleteBody(team_ids=[team_id]),
|
|
response_type=NoBody,
|
|
)
|
|
|
|
def access_group_info(self, access_group: str) -> AccessGroupInfoResponse | None:
|
|
result = self.proxy.transport.get(
|
|
f"/access_group/{access_group}/info",
|
|
headers=self.proxy.transport.master,
|
|
params=NoBody(),
|
|
response_type=AccessGroupInfoResponse,
|
|
)
|
|
return unwrap(result) if is_ok(result) else None
|
|
|
|
def _await_team(self, team_id: str) -> None:
|
|
deadline = time.monotonic() + self.proxy.poll_timeout
|
|
while time.monotonic() < deadline:
|
|
result = self.proxy.transport.get(
|
|
"/team/info",
|
|
headers=self.proxy.transport.master,
|
|
params=TeamInfoParams(team_id=team_id),
|
|
response_type=TeamInfoResponse,
|
|
)
|
|
if is_ok(result):
|
|
return
|
|
time.sleep(self.proxy.poll_interval)
|
|
raise AssertionError(f"/team/info never resolved team {team_id!r} created by /team/new")
|
|
|
|
def create_model_status(self, key: str, model_name: str) -> StreamingResponse:
|
|
return self.proxy.transport.send(
|
|
"/model/new",
|
|
headers=self.proxy.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(proxy: ProxyClient) -> AccessControlClient:
|
|
return AccessControlClient(proxy=proxy)
|