mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-18 04:44:37 +00:00
* test(e2e): perf datastore limits count what a request waits on, background Postgres and Redis work reported on its own postgres_calls_per_request and redis_calls_per_request were whole-server totals (pg_stat_database commits, Redis INFO commands), so spend flushes and config reloads moved them and a new per-request lookup could hide in the noise. They now count the statements and commands a request waits on before it is forwarded (auth and routing reads, the rate-limiter script), from pg_stat_statements read before and after the run and a Redis MONITOR connection held during it. Spend writes, daily aggregates, config reloads and cache invalidations are postgres_background_calls_per_request and redis_background_calls_per_request. The run output lists the top statements and commands per bucket, and the counters are read right after the run so the test's own spend sample is not counted * test(e2e): perf datastore reads only the runtime user's pg_stat_statements rows, other users' rows have a NULL queryid * test(e2e): perf Redis classifier strips the litellm.caching: namespace the deployed gateway puts on every key * test(e2e): small_bodies_5rps limits from ten split-metric runs (postgres awaited 1.8, redis awaited 18, postgres background 12.5), spend buffers and cron locks classified as background * test(e2e): small_bodies_5rps asserts only the awaited Postgres and Redis counts, background lines stay printed * test(e2e): perf Redis classifier puts the cron lock script's GET and DEL in background, not awaited --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""The schema of a perf test: a `PerformanceTestCase` names what the gateway is deployed with
|
|
(model groups and guardrails, both written out in the gateway's config) and lists what the test
|
|
itself creates and sends: teams, Keycloak users, virtual keys, the request shapes, the traffic plan
|
|
and the `Limit`s that decide pass or fail. Add a case by adding a module to `cases/`.
|
|
|
|
The same shape as `CostTrackingTestCase` in the cost suite: frozen dataclasses with plain defaults,
|
|
so a case is a literal a reviewer can read top to bottom.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field
|
|
from typing import Final, Literal
|
|
|
|
Endpoint = Literal["chat_completions", "responses", "messages"]
|
|
MockProvider = Literal["openai", "anthropic", "bedrock"]
|
|
GuardrailMode = Literal["pre_call", "post_call"]
|
|
Metric = Literal[
|
|
"unexpected_status_count",
|
|
"gateway_added_seconds",
|
|
"time_to_first_token_seconds",
|
|
"memory_growth_megabytes",
|
|
"postgres_calls_per_request",
|
|
"postgres_background_calls_per_request",
|
|
"cpu_seconds_per_request",
|
|
"redis_calls_per_request",
|
|
"redis_background_calls_per_request",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Team:
|
|
"""One team (`/team/new`). `models` names the model groups the team may call; empty means all."""
|
|
|
|
name: str
|
|
models: tuple[str, ...] = ()
|
|
max_budget: float | None = None
|
|
rpm_limit: int | None = None
|
|
tpm_limit: int | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class User:
|
|
"""One Keycloak user who calls the gateway with a JWT. `team` puts them in that team's group."""
|
|
|
|
name: str
|
|
team: str | None = None
|
|
max_budget: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VirtualKey:
|
|
"""One virtual key (`/key/generate`), scoped to a team and/or a user when named."""
|
|
|
|
name: str
|
|
team: str | None = None
|
|
user: str | None = None
|
|
models: tuple[str, ...] = ()
|
|
rpm_limit: int | None = None
|
|
max_budget: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Guardrail:
|
|
"""A guardrail the gateway's config defines (a `generic_guardrail_api` pointed at the mock
|
|
guardrail, `default_on`); the case names it so the plan knows which hooks every request sees."""
|
|
|
|
name: str
|
|
mode: GuardrailMode
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RequestShape:
|
|
"""What one request looks like: a coding-agent session (`setup/corpus/session.py`, the system prompt, the
|
|
Read/Edit/Write/Bash/Grep/Glob tools when `tools` is set, then file-reading turns carrying real source files)
|
|
whose size in input tokens is drawn per request from the `input_tokens` range, and the output cap. The mocks
|
|
answer with a reply generated for the request, sized from that cap, a tool call when `tools` is set."""
|
|
|
|
name: str
|
|
input_tokens: tuple[int, int]
|
|
tools: bool = False
|
|
output_tokens: int = 1_024
|
|
|
|
|
|
SHORT: Final = RequestShape("short", input_tokens=(5_000, 100_000), tools=True)
|
|
LONG: Final = RequestShape("long", input_tokens=(300_000, 1_000_000), tools=True)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Traffic:
|
|
"""The plan: `total_requests` drawn from `seed`, sent at `rps` by `concurrency` simulated
|
|
users. Shares are fractions of the requests: `endpoints` and `shapes` must each sum to 1,
|
|
`intervene_share` picks non-streaming requests the guardrail rewrites, `block_share` picks
|
|
requests it blocks."""
|
|
|
|
seed: int
|
|
rps: float
|
|
total_requests: int
|
|
endpoints: Mapping[Endpoint, float] = field(default_factory=lambda: {"chat_completions": 1.0})
|
|
shapes: Mapping[RequestShape, float] = field(default_factory=lambda: {SHORT: 1.0})
|
|
stream_share: float = 0.0
|
|
intervene_share: float = 0.0
|
|
block_share: float = 0.0
|
|
concurrency: int = 20
|
|
|
|
@property
|
|
def expected_seconds(self) -> float:
|
|
return self.total_requests / self.rps
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Limit:
|
|
"""One pass/fail rule: `metric` (at `percentile`, or the whole-run value) must not exceed `max`.
|
|
Metrics without a Limit are still reported."""
|
|
|
|
metric: Metric
|
|
max: float
|
|
percentile: int | None = None
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return f"{self.metric} percentile {self.percentile}" if self.percentile is not None else self.metric
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MockLatency:
|
|
"""Seconds a mock provider waits before its first byte, plus or minus up to `jitter_seconds` per request
|
|
(drawn from the request marker, so a rerun waits the same). Zero means the mocks answer at once."""
|
|
|
|
seconds: float = 0.0
|
|
jitter_seconds: float = 0.0
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PerformanceTestCase:
|
|
"""`model_groups` and `guardrails` name what the gateway config deploys (setup checks they are
|
|
there); everything else the test creates or sends. `mock_latency` is how long every mock provider waits
|
|
before answering, set on the mock pods at setup, so the provider side of each request is a known
|
|
constant and the rest of the client's time is the gateway's."""
|
|
|
|
name: str
|
|
teams: tuple[Team, ...]
|
|
users: tuple[User, ...]
|
|
virtual_keys: tuple[VirtualKey, ...]
|
|
model_groups: tuple[str, ...]
|
|
guardrails: tuple[Guardrail, ...]
|
|
traffic: Traffic
|
|
limits: tuple[Limit, ...]
|
|
mock_latency: MockLatency = field(default_factory=lambda: MockLatency())
|
|
|
|
def team(self, name: str) -> Team:
|
|
return next(team for team in self.teams if team.name == name)
|
|
|
|
@property
|
|
def has_post_guardrail(self) -> bool:
|
|
return any(guardrail.mode == "post_call" for guardrail in self.guardrails)
|
|
|
|
def validate(self) -> None:
|
|
"""Fail fast on a case that cannot run: dangling names, shares that do not add up, a model
|
|
group no caller may use."""
|
|
team_names: Final = {team.name for team in self.teams}
|
|
user_names: Final = {user.name for user in self.users}
|
|
group_names: Final = set(self.model_groups)
|
|
for names, label in (
|
|
([team.name for team in self.teams], "team"),
|
|
([user.name for user in self.users], "user"),
|
|
([key.name for key in self.virtual_keys], "key"),
|
|
(list(self.model_groups), "model group"),
|
|
([guardrail.name for guardrail in self.guardrails], "guardrail"),
|
|
):
|
|
assert len(names) == len(set(names)), f"{self.name}: duplicate {label} names in {names}"
|
|
for team in self.teams:
|
|
assert set(team.models) <= group_names, f"{self.name}: team {team.name} names unknown models {team.models}"
|
|
for user in self.users:
|
|
assert user.team is None or user.team in team_names, (
|
|
f"{self.name}: user {user.name} in unknown team {user.team}"
|
|
)
|
|
for key in self.virtual_keys:
|
|
assert key.team is None or key.team in team_names, f"{self.name}: key {key.name} in unknown team {key.team}"
|
|
assert key.user is None or key.user in user_names, (
|
|
f"{self.name}: key {key.name} for unknown user {key.user}"
|
|
)
|
|
assert set(key.models) <= group_names, f"{self.name}: key {key.name} names unknown models {key.models}"
|
|
assert abs(sum(self.traffic.endpoints.values()) - 1.0) < 1e-9, f"{self.name}: endpoint shares must sum to 1"
|
|
assert abs(sum(self.traffic.shapes.values()) - 1.0) < 1e-9, f"{self.name}: shape shares must sum to 1"
|
|
assert 0 <= self.traffic.stream_share <= 1, f"{self.name}: stream_share out of range"
|
|
assert self.traffic.intervene_share + self.traffic.block_share <= 1, f"{self.name}: guardrail shares exceed 1"
|
|
assert self.traffic.total_requests > 0 and self.traffic.rps > 0, f"{self.name}: empty traffic"
|
|
assert self.model_groups, f"{self.name}: no model groups"
|
|
assert 0 <= self.mock_latency.jitter_seconds <= self.mock_latency.seconds or self.mock_latency.seconds == 0, (
|
|
f"{self.name}: mock latency jitter {self.mock_latency.jitter_seconds} s exceeds {self.mock_latency.seconds} s"
|
|
)
|