mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
* feat(e2e): give e2e tests typed metadata for what they drive @meta(Subject(domain, route, providers, models, capabilities, mode)) declares what a test is about with closed enums, and each field lands in the JUnit report as a property. The quota_management suites are the first to declare it. * docs(e2e): say e2e_metadata avoids litellm, not that it is stdlib-only It already imports pydantic and pytest, both of which the suite needs to collect. The rule that matters is no litellm import * test(e2e): declare models through the constant each test drives 43 @meta declarations in quota_management typed the model name out again, so changing the call would leave the coverage report naming the old model. Each file now has one constant used by both, and a guard fails on any model written as a string literal in @meta * refactor(e2e): set route only when the endpoint is what the test checks A budget or rate-limit test whose chat call only triggers the block now leaves route unset, since its steps already name the call. Tests of an endpoint keep it: budget CRUD, key creation, spend reporting reads, and the per-endpoint spend tests for chat, messages, embeddings, batches and health. The two /spend/logs tests tagged chat_completions are now spend_reporting * refactor(e2e): build the declared properties without mutating a list subject_properties seeded a list and grew it with append and extend. It now flattens one tuple per field, and the plural-name table is a read-only mapping * fix(e2e): tag each spend-route probe with the endpoint it checks The breadth test gave all 33 probes spend_reporting, so /key/list, /user/list, /team/list, /organization/list and /customer/list counted as spend reporting. Each case now carries its own route, with organization and customer management added to Route
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""Typed per-test metadata for the e2e suite: what a test drives (`Subject`) and what it did (`steps`). See AGENTS.md"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
import re
|
|
import string
|
|
import threading
|
|
from collections import deque
|
|
from collections.abc import Callable, Generator, Iterable, Mapping
|
|
from contextlib import AbstractContextManager, contextmanager
|
|
from dataclasses import asdict, dataclass
|
|
from enum import Enum
|
|
from functools import reduce, wraps
|
|
from itertools import chain
|
|
from types import MappingProxyType, TracebackType
|
|
from typing import Final, ParamSpec, TypeVar, cast
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Domain(str, Enum):
|
|
"""The OSS issue-label taxonomy, so an issue and a test join on one string"""
|
|
|
|
LLM_TRANSLATION = "llm-translation"
|
|
SPEND_BUDGETS = "spend-budgets"
|
|
UI = "ui"
|
|
MCP = "mcp"
|
|
OBSERVABILITY = "observability"
|
|
ROUTING = "routing"
|
|
DEPLOY_OPS = "deploy-ops"
|
|
COST_MAP = "cost-map"
|
|
PROXY_AUTH = "proxy-auth"
|
|
GUARDRAILS = "guardrails"
|
|
MANAGEMENT = "management"
|
|
SDK = "sdk"
|
|
PASSTHROUGH = "passthrough"
|
|
DB = "db"
|
|
CACHING = "caching"
|
|
DOCS = "docs"
|
|
AGENTS_API = "agents-api"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class Route(str, Enum):
|
|
"""The endpoint the test is checking; unset when the call only triggers the behavior under test"""
|
|
|
|
CHAT_COMPLETIONS = "chat_completions"
|
|
MESSAGES = "messages"
|
|
RESPONSES = "responses"
|
|
EMBEDDINGS = "embeddings"
|
|
COMPLETIONS = "completions"
|
|
FILES = "files"
|
|
BATCHES = "batches"
|
|
PASSTHROUGH = "passthrough"
|
|
MCP = "mcp"
|
|
GUARDRAILS = "guardrails"
|
|
KEY_MANAGEMENT = "key_management"
|
|
TEAM_MANAGEMENT = "team_management"
|
|
SPEND_REPORTING = "spend_reporting"
|
|
MODEL_MANAGEMENT = "model_management"
|
|
IMAGES = "images"
|
|
AUDIO = "audio"
|
|
MODERATIONS = "moderations"
|
|
RERANK = "rerank"
|
|
OCR = "ocr"
|
|
VECTOR_STORES = "vector_stores"
|
|
REALTIME = "realtime"
|
|
A2A = "a2a"
|
|
USER_MANAGEMENT = "user_management"
|
|
BUDGET_MANAGEMENT = "budget_management"
|
|
ORGANIZATION_MANAGEMENT = "organization_management"
|
|
CUSTOMER_MANAGEMENT = "customer_management"
|
|
HEALTH = "health"
|
|
METRICS = "metrics"
|
|
PROXY_CONFIG = "proxy_config"
|
|
ADMIN_UI = "admin_ui"
|
|
|
|
|
|
class Provider(str, Enum):
|
|
"""Mirrors litellm's `LlmProviders` without importing litellm; `TestProviderMirrorsLitellm` catches drift"""
|
|
|
|
OPENAI = "openai"
|
|
OPENAI_LIKE = "openai_like"
|
|
CUSTOM_OPENAI = "custom_openai"
|
|
AZURE = "azure"
|
|
AZURE_AI = "azure_ai"
|
|
ANTHROPIC = "anthropic"
|
|
GEMINI = "gemini"
|
|
VERTEX_AI = "vertex_ai"
|
|
BEDROCK = "bedrock"
|
|
SAGEMAKER = "sagemaker"
|
|
XAI = "xai"
|
|
GROQ = "groq"
|
|
DEEPSEEK = "deepseek"
|
|
MISTRAL = "mistral"
|
|
COHERE = "cohere"
|
|
PERPLEXITY = "perplexity"
|
|
OPENROUTER = "openrouter"
|
|
TOGETHER_AI = "together_ai"
|
|
FIREWORKS_AI = "fireworks_ai"
|
|
CEREBRAS = "cerebras"
|
|
SAMBANOVA = "sambanova"
|
|
NVIDIA_NIM = "nvidia_nim"
|
|
DATABRICKS = "databricks"
|
|
WATSONX = "watsonx"
|
|
OLLAMA = "ollama"
|
|
VLLM = "vllm"
|
|
HOSTED_VLLM = "hosted_vllm"
|
|
VOYAGE = "voyage"
|
|
JINA_AI = "jina_ai"
|
|
DEEPGRAM = "deepgram"
|
|
ELEVENLABS = "elevenlabs"
|
|
ASSEMBLYAI = "assemblyai"
|
|
LITELLM_PROXY = "litellm_proxy"
|
|
|
|
|
|
class Capability(str, Enum):
|
|
"""A model feature, 1:1 with a `supports_*` key in model_prices_and_context_window.json"""
|
|
|
|
FUNCTION_CALLING = "function_calling"
|
|
PARALLEL_FUNCTION_CALLING = "parallel_function_calling"
|
|
TOOL_CHOICE = "tool_choice"
|
|
TOOL_SEARCH = "tool_search"
|
|
VISION = "vision"
|
|
PDF_INPUT = "pdf_input"
|
|
AUDIO_INPUT = "audio_input"
|
|
REASONING = "reasoning"
|
|
WEB_SEARCH = "web_search"
|
|
PROMPT_CACHING = "prompt_caching"
|
|
RESPONSE_SCHEMA = "response_schema"
|
|
MID_CONVERSATION_SYSTEM = "mid_conversation_system"
|
|
|
|
|
|
class Mode(str, Enum):
|
|
"""How the route was driven"""
|
|
|
|
NONSTREAM = "nonstream"
|
|
STREAM = "stream"
|
|
BATCH = "batch"
|
|
WEBSOCKET = "websocket"
|
|
|
|
|
|
_M = TypeVar("_M")
|
|
|
|
|
|
def _scalar(value: object) -> str:
|
|
"""`str()` on a (str, Enum) gives `Route.RESPONSES`, and StrEnum needs 3.11"""
|
|
if isinstance(value, Enum):
|
|
return str(value.value) # pyright: ignore[reportAny] # Enum.value is Any for every enum
|
|
return str(value)
|
|
|
|
|
|
def _members(value: object) -> tuple[object, ...] | None:
|
|
return cast("tuple[object, ...]", value) if isinstance(value, tuple) else None
|
|
|
|
|
|
def _canonical(name: str, value: object, member_type: type[_M]) -> tuple[_M, ...]:
|
|
"""Validated, deduped and sorted; a bare str like `("gpt-5.5")` raises at import"""
|
|
members = _members(value)
|
|
if members is None:
|
|
raise TypeError(
|
|
f"Subject.{name} must be a tuple, got {type(value).__name__}: {value!r}."
|
|
f" A one-member tuple needs its trailing comma: {name}=(x,), not {name}=(x)"
|
|
)
|
|
typed = tuple(member for member in members if isinstance(member, member_type))
|
|
if len(typed) != len(members):
|
|
raise TypeError(f"Subject.{name} takes {member_type.__name__} members, got {value!r}")
|
|
return tuple(sorted(frozenset(member for member in typed if _scalar(member)), key=_scalar))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Subject:
|
|
"""What a test is about. Not named `Test*` so pytest does not try to collect it"""
|
|
|
|
domain: Domain | None = None
|
|
route: Route | None = None
|
|
providers: tuple[Provider, ...] = ()
|
|
models: tuple[str, ...] = ()
|
|
capabilities: tuple[Capability, ...] = ()
|
|
mode: Mode | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "providers", _canonical("providers", self.providers, Provider))
|
|
object.__setattr__(self, "models", _canonical("models", self.models, str))
|
|
object.__setattr__(self, "capabilities", _canonical("capabilities", self.capabilities, Capability))
|
|
|
|
|
|
def meta(subject: Subject) -> pytest.MarkDecorator:
|
|
"""Attach a `Subject` to a test: `@meta(Subject(route=Route.RESPONSES, ...))`"""
|
|
return pytest.mark.meta(subject)
|
|
|
|
|
|
_P = ParamSpec("_P")
|
|
_R = TypeVar("_R")
|
|
_Y = TypeVar("_Y")
|
|
|
|
MAX_STEPS: Final = 50
|
|
MAX_STEP_CHARS: Final = 200
|
|
|
|
SECRET_ENV_NAME: Final = re.compile(r"(^|_)(KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?)(_|$)", re.IGNORECASE)
|
|
MIN_SECRET_CHARS: Final = 8
|
|
MASK: Final = "***"
|
|
|
|
|
|
def environment_secrets(environ: Mapping[str, str] = os.environ) -> frozenset[str]:
|
|
"""The credentials a live run holds: every secret-named environment variable's
|
|
value, long enough that masking it can't blank out ordinary words."""
|
|
return frozenset(
|
|
value for name, value in environ.items() if SECRET_ENV_NAME.search(name) and len(value) >= MIN_SECRET_CHARS
|
|
)
|
|
|
|
|
|
def _masked(label: str, secrets: Iterable[str]) -> str:
|
|
longest_first: Final = sorted(secrets, key=len, reverse=True)
|
|
return reduce(lambda text, secret: text.replace(secret, MASK), longest_first, label)
|
|
|
|
|
|
STEP_FRAMES: Final = 1
|
|
"""Frames a `@step` wrapper puts between a helper and its caller. A decorated
|
|
helper that warns about its caller adds this to `stacklevel`
|
|
(`stacklevel=2 + STEP_FRAMES`), or the warning is reported at the wrapper."""
|
|
|
|
|
|
class StepRecorder:
|
|
"""The ordered step log for the running test.
|
|
|
|
A plain lock-guarded list rather than a ContextVar: ContextVars do not
|
|
propagate into worker threads, and several e2e helpers call out from
|
|
threads. Under xdist each worker is its own process, so there is no
|
|
cross-test bleed beyond what the per-test reset already handles.
|
|
"""
|
|
|
|
def __init__(self, secrets: Callable[[], Iterable[str]] = environment_secrets) -> None:
|
|
self._secrets = secrets
|
|
self._lock = threading.Lock()
|
|
self._steps: deque[str] = deque(maxlen=MAX_STEPS)
|
|
self._dropped = 0
|
|
|
|
def reset(self) -> None:
|
|
"""Called first thing in every test's setup phase, so each test starts
|
|
empty."""
|
|
with self._lock:
|
|
self._steps.clear()
|
|
self._dropped = 0
|
|
|
|
def record(self, label: str) -> None:
|
|
"""Append `label`, unless it repeats the previous step.
|
|
|
|
A retrying helper (poll_cost_row) or a load test calling a decorated
|
|
helper in a loop would otherwise emit thousands of <property> entries per
|
|
testcase: a consecutive repeat collapses, so a poll loop is one step in
|
|
the story rather than fifty, and past MAX_STEPS the oldest step makes way.
|
|
It is the oldest that goes because the last step is the one that has to
|
|
survive: it is where a failing test died.
|
|
|
|
Any credential the run holds is masked before the label is kept, however it
|
|
got into the label, since the steps are published with the results.
|
|
"""
|
|
cleaned = " ".join(_masked(label, self._secrets()).split())[:MAX_STEP_CHARS]
|
|
if not cleaned:
|
|
return
|
|
with self._lock:
|
|
if self._steps and self._steps[-1] == cleaned:
|
|
return
|
|
if len(self._steps) == MAX_STEPS:
|
|
self._dropped += 1
|
|
self._steps.append(cleaned)
|
|
|
|
def taken(self) -> tuple[str, ...]:
|
|
"""The story so far, led by a line counting the steps a full log dropped,
|
|
so a story that starts mid-test says so rather than reading as complete."""
|
|
with self._lock:
|
|
dropped: Final = (f"({self._dropped} earlier steps not recorded)",) if self._dropped else ()
|
|
return dropped + tuple(self._steps)
|
|
|
|
|
|
STEPS: Final = StepRecorder()
|
|
|
|
|
|
def _joined(phrases: tuple[str, ...]) -> str:
|
|
if len(phrases) <= 1:
|
|
return "".join(phrases)
|
|
return f"{', '.join(phrases[:-1])} and {phrases[-1]}"
|
|
|
|
|
|
def _model_phrase(model: BaseModel) -> str:
|
|
"""The fields the caller set, as "models: a, b and rpm limit: 3". A
|
|
`Field(repr=False)` field, pydantic's flag for a secret, is never shown."""
|
|
values: Final = (
|
|
(name, cast("object", getattr(model, name)))
|
|
for name, field in type(model).model_fields.items()
|
|
if name in model.model_fields_set and field.repr
|
|
)
|
|
phrases: Final = tuple(f"{name.replace('_', ' ')}: {_phrase(value)}" for name, value in values if _given(value))
|
|
return _joined(phrases) or "default settings"
|
|
|
|
|
|
def _given(value: object) -> bool:
|
|
return value is not None and value != [] and value != ()
|
|
|
|
|
|
def _phrase(value: object) -> str:
|
|
if isinstance(value, BaseModel):
|
|
return _model_phrase(value)
|
|
if isinstance(value, Enum):
|
|
return _phrase(cast("object", value.value))
|
|
if isinstance(value, Mapping):
|
|
entries: Final = cast("Mapping[object, object]", value)
|
|
return _joined(tuple(f"{str(key).replace('_', ' ')}: {_phrase(item)}" for key, item in entries.items()))
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return ", ".join(map(_phrase, cast("Iterable[object]", value)))
|
|
return str(value)
|
|
|
|
|
|
_PLACEHOLDER: Final = re.compile(r"[A-Za-z_]\w*(\.[A-Za-z_]\w*)*")
|
|
|
|
|
|
def _placeholders(label: str) -> frozenset[str]:
|
|
return frozenset(field for _, field, _, _ in string.Formatter().parse(label) if field is not None)
|
|
|
|
|
|
def _resolved(field: str, arguments: Mapping[str, object]) -> object:
|
|
"""`body.litellm_params.model` is the `body` argument's `litellm_params.model`."""
|
|
root, *attributes = field.split(".")
|
|
return reduce(lambda value, attribute: cast("object", getattr(value, attribute)), attributes, arguments[root])
|
|
|
|
|
|
def _filled(label: str, bound: inspect.BoundArguments) -> str:
|
|
bound.apply_defaults()
|
|
arguments: Final = cast("Mapping[str, object]", bound.arguments)
|
|
return "".join(
|
|
literal + ("" if field is None else _phrase(_resolved(field, arguments)))
|
|
for literal, field, _, _ in string.Formatter().parse(label)
|
|
)
|
|
|
|
|
|
class _Nesting(threading.local):
|
|
"""Whether this thread is already inside a `@step` helper.
|
|
|
|
Per thread, like the helpers themselves: a worker thread a step fans out to
|
|
starts outside any step, so its own decorated calls still record."""
|
|
|
|
def __init__(self) -> None:
|
|
self.inside: bool = False
|
|
|
|
|
|
_NESTING: Final = _Nesting()
|
|
|
|
|
|
@contextmanager
|
|
def _inside_step() -> Generator[None]:
|
|
"""Hold the nesting guard for the duration, restoring whatever it was."""
|
|
outer: Final = _NESTING.inside
|
|
_NESTING.inside = True
|
|
try:
|
|
yield
|
|
finally:
|
|
_NESTING.inside = outer
|
|
|
|
|
|
class _StepContext(AbstractContextManager[_Y]):
|
|
"""A `@contextmanager` helper's context, entered and exited inside its step.
|
|
|
|
Calling a `@contextmanager` function runs none of its body: the setup runs at
|
|
`__enter__` and the cleanup at `__exit__`, both after the call has returned
|
|
and so both outside the guard the call held. Here each runs inside it, so the
|
|
helpers they call stay out of the story, while the `with` body in between --
|
|
the test's own code -- still records. Without this, a test that died inside
|
|
the `with` would have the cleanup's steps appended behind the one it died on.
|
|
"""
|
|
|
|
def __init__(self, inner: AbstractContextManager[_Y]) -> None:
|
|
self._inner: Final = inner
|
|
|
|
def __enter__(self) -> _Y:
|
|
with _inside_step():
|
|
return self._inner.__enter__()
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
traceback: TracebackType | None,
|
|
) -> bool | None:
|
|
with _inside_step():
|
|
return self._inner.__exit__(exc_type, exc, traceback)
|
|
|
|
|
|
def step(label: str) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
|
"""Record `label` on the running test whenever this helper is called.
|
|
|
|
Goes on HARNESS helpers (client methods, fixtures), never on tests. The
|
|
label is recorded BEFORE the wrapped call, so a helper that raises still
|
|
leaves its own label as the last element -- which is the whole point: the
|
|
last step is where the test died.
|
|
|
|
Only the outermost step records. Harness layers call each other --
|
|
`ProxyClient.create_model` goes through `register_model`, a domain
|
|
client wraps the shared `ProxyClient` -- so every layer can carry its own
|
|
label without one action showing up in the story once per layer. The story
|
|
reads at the level the test called in at, and the label of the helper the
|
|
test called is still the last one when anything beneath it raises.
|
|
|
|
On a `@contextmanager` helper `@step` goes ABOVE `@contextmanager`, and the
|
|
setup and cleanup around its `yield` count as part of the step (see
|
|
`_StepContext`). A bare generator function is refused where the decorator
|
|
runs: its body only runs as the caller iterates, interleaved with the
|
|
caller's own steps, so no single point in the story is where it happened.
|
|
"""
|
|
|
|
def decorate(fn: Callable[_P, _R]) -> Callable[_P, _R]:
|
|
signature: Final = inspect.signature(fn)
|
|
placeholders: Final = _placeholders(label)
|
|
malformed: Final = sorted(field for field in placeholders if not _PLACEHOLDER.fullmatch(field))
|
|
if malformed:
|
|
raise TypeError(f"@step({label!r}) has {malformed}: a placeholder is a parameter or its dotted attribute")
|
|
unknown: Final = {field.split(".")[0] for field in placeholders} - signature.parameters.keys()
|
|
if unknown:
|
|
raise TypeError(f"@step({label!r}) names {sorted(unknown)}, which {fn.__qualname__} doesn't take")
|
|
static_label: Final = None if placeholders else label.format()
|
|
if inspect.isgeneratorfunction(fn):
|
|
raise TypeError(
|
|
f"@step({label!r}) cannot wrap the generator function {fn!r}: put it on a helper that"
|
|
" returns, or above @contextmanager on one that yields a context"
|
|
)
|
|
underlying: Final[object] = inspect.unwrap(fn) # pyright: ignore[reportAny] # inspect.unwrap is typed as returning Any
|
|
opens_a_context: Final = inspect.isgeneratorfunction(underlying)
|
|
|
|
@wraps(fn)
|
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
if not _NESTING.inside:
|
|
STEPS.record(static_label or _filled(label, signature.bind(*args, **kwargs)))
|
|
with _inside_step():
|
|
result = fn(*args, **kwargs)
|
|
if opens_a_context and isinstance(result, AbstractContextManager):
|
|
context: Final = cast("AbstractContextManager[object]", result)
|
|
return cast("_R", _StepContext(context))
|
|
return result
|
|
|
|
return wrapper
|
|
|
|
return decorate
|
|
|
|
|
|
_REPEATED: Final = MappingProxyType({"providers": "provider", "models": "model", "capabilities": "capability"})
|
|
|
|
|
|
def _declared_subject(args: tuple[object, ...]) -> Subject | None:
|
|
first = args[0] if args else None
|
|
return first if isinstance(first, Subject) else None
|
|
|
|
|
|
def subject_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]:
|
|
"""The declared fields as <property> pairs, plural fields repeated under their singular name"""
|
|
marker: Final = item.get_closest_marker("meta")
|
|
if marker is None:
|
|
return ()
|
|
subject: Final = _declared_subject(marker.args)
|
|
if subject is None:
|
|
return ()
|
|
declared: Final[dict[str, object]] = asdict(subject)
|
|
return tuple(chain.from_iterable(_field_properties(name, value) for name, value in declared.items()))
|
|
|
|
|
|
def _field_properties(name: str, value: object) -> tuple[tuple[str, str], ...]:
|
|
repeated: Final = _REPEATED.get(name)
|
|
if repeated is not None:
|
|
return tuple((repeated, _scalar(member)) for member in _members(value) or ())
|
|
if value is None or value == "":
|
|
return ()
|
|
return ((name, _scalar(value)),)
|
|
|
|
|
|
def step_properties() -> tuple[tuple[str, str], ...]:
|
|
"""The step log as repeated `step` properties. Appended after the setup and
|
|
call phases, never at collection."""
|
|
return tuple(("step", label) for label in STEPS.taken())
|