mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
Let a test declare several providers and models, and keep steps on setup errors
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
Subject.provider and Subject.model become the tuples providers and models, handled exactly like capabilities: empty by default, deduped and sorted at declaration, and written to JUnit as a repeated property under the singular name (provider, model). One test node often drives several, such as the claude_code matrix running haiku, sonnet and opus in one body, and the two lists are independent sets with no positional pairing. Every plural field now refuses anything but a tuple, so models=("gpt-5.5") is a collection error naming the file instead of one model per character
All 27 pilot files move to the plural form. The fallback, per-model budget, shared-key and key-attribution tests now declare every provider and model they drive, the health-check test declares the Gemini model it probes, and the cost-breakdown test names the backend it actually registers
Steps are now attached after setup as well as after call, so a test that errors in a fixture keeps the steps recorded before the crash. The step log is emptied from the pytest_runtest_setup hook instead of an autouse fixture, because a fixture runs after wider-scoped ones and a setup error would otherwise inherit steps left by the previous module's finalizer. Teardown deliberately does not attach, so a failing test's last step stays the one it died on
test_junit_report.py runs real pytest with --junitxml against this conftest, in-process and under -n 2, and asserts on the parsed XML for passing, failing, setup-error and rerun cases plus the repeated provider, model and capability properties. Decorative comments in the files this branch adds are removed
This commit is contained in:
parent
ee5efaed7b
commit
f484cc2366
34 changed files with 718 additions and 254 deletions
|
|
@ -138,19 +138,19 @@ Separate from the coverage registry and additive to it: `@meta(Subject(...))` fr
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CHEAP_ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(CHEAP_ANTHROPIC_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_bare_key_blocks_over_its_own_budget(...) -> None: ...
|
||||
```
|
||||
|
||||
Every field is optional today (the backfill of the rest of the suite is a later PR) and every field is a closed enum, so a typo is a basedpyright error at the call site rather than a property that silently never appears. `capabilities` is a tuple even with one member, and it is deduped and sorted at declaration so the committed run artifacts diff cleanly. `Subject` is serialized with `dataclasses.asdict`, so a new scalar field needs no serializer edit; empty fields emit no `<property>` at all. A declared `model` names the constant the test drives (`CHEAP_ANTHROPIC_MODEL`, the file's own `BACKEND`), never a copy of its value, so the property cannot claim one model while an env override runs another. `e2e_metadata` is stdlib-only and so are its call sites: `Provider` mirrors litellm's `LlmProviders` values instead of importing them, because tests/e2e is shipped to the runner image on its own and a `from litellm...` at module scope would make the litellm package a hard dependency of COLLECTING the suite. `TestProviderMirrorsLitellm` in `test_junit_properties.py` fails on drift wherever litellm is importable and skips where it is not, so adding a provider is one line in `e2e_metadata`
|
||||
Every field is optional today (the backfill of the rest of the suite is a later PR) and every field is a closed enum, so a typo is a basedpyright error at the call site rather than a property that silently never appears. `providers`, `models` and `capabilities` are tuples even with one member, because one test node routinely drives several: the claude_code matrix runs haiku, sonnet and opus in a single body, and a spend test calls two providers on one key. Declare every provider and every model the test drives, fallbacks included. The three are independent sets with no positional pairing between them (one provider x three models is the common case), and each is deduped and sorted at declaration so the committed run artifacts diff cleanly. `models=("gpt-5.5")` is a str and not a tuple, so anything but a tuple raises a `TypeError` where the decorator runs and shows up as a collection error naming the file. `Subject` is serialized with `dataclasses.asdict`, so a new scalar field needs no serializer edit; empty fields emit no `<property>` at all. A declared model names the constant the test drives (`CHEAP_ANTHROPIC_MODEL`, the file's own `BACKEND`), never a copy of its value, so the property cannot claim one model while an env override runs another. `e2e_metadata` is stdlib-only and so are its call sites: `Provider` mirrors litellm's `LlmProviders` values instead of importing them, because tests/e2e is shipped to the runner image on its own and a `from litellm...` at module scope would make the litellm package a hard dependency of COLLECTING the suite. `TestProviderMirrorsLitellm` in `test_junit_properties.py` fails on drift wherever litellm is importable and skips where it is not, so adding a provider is one line in `e2e_metadata`
|
||||
|
||||
The other half is recorded, not declared. `@step("POST /chat/completions")` goes on HARNESS helpers - client methods, `ResourceManager.key`, poll loops - never on a test, and appends its label to the running test's `user_properties` in call order. The list IS the test's user story, and because the label is recorded BEFORE the wrapped call, a failing test's LAST step is where it died. Consecutive duplicates collapse and the log caps at 50 entries, so a poll loop is one beat of the story rather than fifty. Nothing about steps is hand-written: the call sequence cannot drift from what the test actually did
|
||||
The other half is recorded, not declared. `@step("POST /chat/completions")` goes on HARNESS helpers - client methods, `ResourceManager.key`, poll loops - never on a test, and appends its label to the running test's `user_properties` in call order. The list IS the test's user story, and because the label is recorded BEFORE the wrapped call, a failing test's LAST step is where it died. Consecutive duplicates collapse and the log caps at 50 entries, so a poll loop is one beat of the story rather than fifty. Nothing about steps is hand-written: the call sequence cannot drift from what the test actually did. The log is emptied first thing in every test's setup phase and attached after setup and again after call, so a test that errors in a fixture keeps the steps recorded before the crash. Teardown steps are left out on purpose: they are cleanup, and listing them would put a finalizer's step after the one a failing test died on
|
||||
|
||||
Both halves ride out as JUnit `<property>` entries (`junit_properties.py`), repeated rather than delimiter-joined, since a free-text label has no separator that can be reserved
|
||||
Both halves ride out as JUnit `<property>` entries (`junit_properties.py`). Every plural value is a repeated property under its SINGULAR name (`provider`, `model`, `capability`, `step`) rather than one delimiter-joined value, since a free-text label has no separator that can be reserved. The results JSON downstream regroups them under the plural key, so `providers` and `models` are arrays there, `[]` when empty. `test_junit_report.py` runs real pytest with `--junitxml`, in-process and under `-n 2`, and pins what reaches the XML
|
||||
|
||||
## Coverage registry
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ def pytest_configure(config: pytest.Config) -> None:
|
|||
config.addinivalue_line(
|
||||
"markers",
|
||||
"meta(subject): typed e2e_metadata.Subject describing what this test drives"
|
||||
" (domain/route/provider/model/capabilities/mode); attach it with @meta(Subject(...))",
|
||||
" (domain/route/providers/models/capabilities/mode); attach it with @meta(Subject(...))",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
|
|
@ -238,7 +238,14 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
|
|||
"""Hard-fail `e2e`-marked tests unless a proxy answers its liveness probe.
|
||||
Unmarked tests (unit coverage of the harness) don't touch the proxy, so they
|
||||
run even when none is up. Never skip for a missing proxy. Replay mode needs
|
||||
the proxy too: only provider-bound traffic replays from the bundle."""
|
||||
the proxy too: only provider-bound traffic replays from the bundle.
|
||||
|
||||
Also empties the step log, so the story a test tells is its own. It happens
|
||||
here, first in the setup phase, rather than in a fixture: a fixture only runs
|
||||
once every wider-scoped fixture ahead of it has been set up, so a step a
|
||||
module-scoped finalizer recorded after the previous test would still be in
|
||||
the log when this test's setup dies early, and would be reported as its own."""
|
||||
STEPS.reset()
|
||||
LIVE_PROVIDER_REQUIRED.set(item.get_closest_marker("provider_live") is not None)
|
||||
if item.get_closest_marker("e2e") is None or item.get_closest_marker("migration_startup") is not None:
|
||||
return
|
||||
|
|
@ -269,9 +276,19 @@ def pytest_runtest_makereport(
|
|||
|
||||
The steps cannot ride along with the other properties in
|
||||
`pytest_collection_modifyitems`: that hook runs before any test body has, so
|
||||
the recorder is empty there. They are attached on every call-phase outcome,
|
||||
failures included -- a failing test's last step is where it died, which is the
|
||||
whole reason the field exists.
|
||||
the recorder is empty there. They are attached after setup and again after
|
||||
call, on every outcome -- a failing test's last step is where it died, which
|
||||
is the whole reason the field exists. Setup has to attach too because a test
|
||||
whose fixture raises never reaches the call phase, and setup is where an e2e
|
||||
test most often dies (proxy not ready, key creation failing). The second
|
||||
attach replaces the first, so nothing is doubled. JUnit writes properties
|
||||
from the teardown report, which pytest builds from `item.user_properties`
|
||||
after both of these have run.
|
||||
|
||||
Teardown deliberately does not attach. Steps recorded by fixture finalizers
|
||||
are cleanup, and appending them would put "delete virtual key" after the step
|
||||
a failing test died on, which breaks the one guarantee the field makes. A
|
||||
finalizer that raises is still reported by JUnit with its own traceback.
|
||||
"""
|
||||
report = yield
|
||||
if item.get_closest_marker("mcp_oauth_live") is not None and call.excinfo is not None:
|
||||
|
|
@ -283,6 +300,7 @@ def pytest_runtest_makereport(
|
|||
report.user_properties = list(item.user_properties)
|
||||
if report.when == "call":
|
||||
item.stash[_CALL_PASSED] = report.passed
|
||||
if report.when in ("setup", "call"):
|
||||
attach_step_properties(item)
|
||||
return report
|
||||
|
||||
|
|
@ -320,21 +338,6 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _record_steps() -> Iterator[None]: # pyright: ignore[reportUnusedFunction] # autouse: nothing names it
|
||||
"""Empty the step log before each test, so the story a test tells is its own.
|
||||
|
||||
Autouse and unconditional: the recorder is written by @step-decorated harness
|
||||
helpers, which a test reaches through fixtures as readily as through its own
|
||||
body, so setup-phase steps have to be kept too. The log is read after the call
|
||||
phase by `pytest_runtest_makereport`; a test that dies partway keeps the
|
||||
partial list, which is the point -- its last element is where it died.
|
||||
"""
|
||||
STEPS.reset()
|
||||
yield
|
||||
STEPS.reset()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def proxy() -> ProxyClient:
|
||||
"""The shared ProxyClient every suite's client is built from. Suite `client`
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
Two halves, deliberately separated.
|
||||
|
||||
The DECLARED half is `Subject`: one frozen dataclass passed as the single
|
||||
positional argument of `@meta(...)`. Every field is a closed enum (or a free
|
||||
string for `model`), so a typo is a basedpyright error at the call site rather
|
||||
positional argument of `@meta(...)`. Every field is a closed enum (or free
|
||||
strings for `models`), so a typo is a basedpyright error at the call site rather
|
||||
than a silently dropped property. `dataclasses.asdict()` turns the whole thing
|
||||
into <property> pairs with no per-field plumbing -- adding a scalar field later
|
||||
needs zero serializer changes.
|
||||
|
|
@ -29,7 +29,7 @@ from __future__ import annotations
|
|||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Final, ParamSpec, TypeVar, cast
|
||||
|
|
@ -79,10 +79,12 @@ class Route(str, Enum):
|
|||
images_generations + images_edits -> IMAGES, audio_speech +
|
||||
audio_transcriptions -> AUDIO, bedrock_native + google_native ->
|
||||
PASSTHROUGH. Those splits are wire detail, not a customer-facing surface,
|
||||
and `model` + `capabilities` already carry them.
|
||||
and `models` + `capabilities` already carry them.
|
||||
|
||||
The last four are ops surfaces: logging/, load/, other/ and ui/ have no LLM
|
||||
route of their own and would otherwise have to lie.
|
||||
"""
|
||||
|
||||
# Core: each is the subject of many e2e tests.
|
||||
CHAT_COMPLETIONS = "chat_completions"
|
||||
MESSAGES = "messages"
|
||||
RESPONSES = "responses"
|
||||
|
|
@ -97,8 +99,6 @@ class Route(str, Enum):
|
|||
TEAM_MANAGEMENT = "team_management"
|
||||
SPEND_REPORTING = "spend_reporting"
|
||||
MODEL_MANAGEMENT = "model_management"
|
||||
|
||||
# Long tail: real, but one or two test files each.
|
||||
IMAGES = "images"
|
||||
AUDIO = "audio"
|
||||
MODERATIONS = "moderations"
|
||||
|
|
@ -109,9 +109,6 @@ class Route(str, Enum):
|
|||
A2A = "a2a"
|
||||
USER_MANAGEMENT = "user_management"
|
||||
BUDGET_MANAGEMENT = "budget_management"
|
||||
|
||||
# Ops surfaces. logging/, load/, other/ and ui/ have no LLM route of their
|
||||
# own and would otherwise have to lie.
|
||||
HEALTH = "health"
|
||||
METRICS = "metrics"
|
||||
PROXY_CONFIG = "proxy_config"
|
||||
|
|
@ -212,6 +209,52 @@ class Mode(str, Enum):
|
|||
WEBSOCKET = "websocket"
|
||||
|
||||
|
||||
_M = TypeVar("_M")
|
||||
|
||||
|
||||
def _scalar(value: object) -> str:
|
||||
"""`str(member)` on a (str, Enum) gives 'Route.RESPONSES', not 'responses'
|
||||
-- StrEnum would not, but it is 3.11+ and this repo floors at 3.10. So the
|
||||
value is read explicitly, once, for every enum field."""
|
||||
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:
|
||||
"""The elements of a plural field, or None for anything that is not a tuple.
|
||||
|
||||
Both callers hold the value as a plain object: `_canonical` because a call
|
||||
site can pass anything at runtime, the serializer because `asdict` hands the
|
||||
tuple back inside an untyped dict. The elements are re-declared as plain
|
||||
objects here and converted by `_scalar` like any other value.
|
||||
"""
|
||||
return cast("tuple[object, ...]", value) if isinstance(value, tuple) else None
|
||||
|
||||
|
||||
def _canonical(name: str, value: object, member_type: type[_M]) -> tuple[_M, ...]:
|
||||
"""A plural field's members: validated, deduped, and sorted by the value
|
||||
they serialize to.
|
||||
|
||||
`models=("gpt-5.5")` is a str, not a tuple, and iterating it would declare
|
||||
one model per character. Anything that is not a tuple is refused here, which
|
||||
runs where the decorator does: at import, so pytest reports a collection
|
||||
error naming the file instead of shipping garbage properties. An empty
|
||||
string member is dropped rather than refused, because `models` is fed from
|
||||
env-overridable constants and a blank override must not break collection.
|
||||
"""
|
||||
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.
|
||||
|
|
@ -220,24 +263,29 @@ class Subject:
|
|||
backfill of the existing ~908 tests comes later. Named `Subject` rather than
|
||||
`TestMeta` because pytest tries to collect any imported class named `Test*`
|
||||
and would warn in every one of the ~570 modules that import it.
|
||||
|
||||
`providers`, `models` and `capabilities` are plural because one test node
|
||||
routinely drives several: the claude_code matrix runs haiku, sonnet and opus
|
||||
in a single body, and a spend test calls two providers on one key. Each is
|
||||
an independent set. No positional pairing is implied between `providers` and
|
||||
`models` (one provider x three models is the common case), and none could
|
||||
survive anyway, since each tuple is deduped and sorted on its own.
|
||||
"""
|
||||
|
||||
domain: Domain | None = None
|
||||
route: Route | None = None
|
||||
provider: Provider | None = None
|
||||
model: str | None = None
|
||||
providers: tuple[Provider, ...] = ()
|
||||
models: tuple[str, ...] = ()
|
||||
capabilities: tuple[Capability, ...] = ()
|
||||
mode: Mode | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Canonicalize capabilities at declaration: deduped and sorted by
|
||||
value, so the committed run files diff cleanly however a test spelled
|
||||
the tuple, and the serializer stays field-agnostic."""
|
||||
object.__setattr__(
|
||||
self,
|
||||
"capabilities",
|
||||
tuple(sorted(dict.fromkeys(self.capabilities), key=lambda c: c.value)),
|
||||
)
|
||||
"""Canonicalize every plural field at declaration, so the committed run
|
||||
files diff cleanly however a test spelled the tuple, and the serializer
|
||||
stays field-agnostic."""
|
||||
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:
|
||||
|
|
@ -255,13 +303,9 @@ def meta(subject: Subject) -> pytest.MarkDecorator:
|
|||
return pytest.mark.meta(subject)
|
||||
|
||||
|
||||
# --- The recorded half: steps -------------------------------------------------
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
# 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.
|
||||
MAX_STEPS: Final = 50
|
||||
MAX_STEP_CHARS: Final = 200
|
||||
|
||||
|
|
@ -280,16 +324,23 @@ class _StepRecorder:
|
|||
self._steps: list[str] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Called by an autouse fixture at setup, so each test starts empty."""
|
||||
"""Called first thing in every test's setup phase, so each test starts
|
||||
empty."""
|
||||
with self._lock:
|
||||
self._steps.clear()
|
||||
|
||||
def record(self, label: str) -> None:
|
||||
"""Append `label`, unless it repeats the previous step or the log is full.
|
||||
|
||||
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 the log stops growing at MAX_STEPS.
|
||||
"""
|
||||
cleaned = " ".join(label.split())[:MAX_STEP_CHARS]
|
||||
if not cleaned:
|
||||
return
|
||||
with self._lock:
|
||||
# A poll loop is one step in the story, not fifty.
|
||||
if self._steps and self._steps[-1] == cleaned:
|
||||
return
|
||||
if len(self._steps) >= MAX_STEPS:
|
||||
|
|
@ -324,33 +375,7 @@ def step(label: str) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
|||
return decorate
|
||||
|
||||
|
||||
# --- Serialization: asdict() -> <property> pairs, no per-field plumbing -------
|
||||
|
||||
# The only field-specific knowledge in the module: which fields are plural, and
|
||||
# what their repeated <property> is called. A new SCALAR field needs no edit.
|
||||
_REPEATED: Final[dict[str, str]] = {"capabilities": "capability"}
|
||||
|
||||
|
||||
def _scalar(value: object) -> str:
|
||||
"""`str(member)` on a (str, Enum) gives 'Route.RESPONSES', not 'responses'
|
||||
-- StrEnum would not, but it is 3.11+ and this repo floors at 3.10. So the
|
||||
value is read explicitly, once, for every enum field."""
|
||||
if isinstance(value, Enum):
|
||||
# `Enum.value` is `Any` by construction, for every enum there has ever
|
||||
# been; this is the one place in the module that reads it, and `str()`
|
||||
# lands it back in the type system immediately.
|
||||
return str(value.value) # pyright: ignore[reportAny] # Enum.value is Any for every enum
|
||||
return str(value)
|
||||
|
||||
|
||||
def _members(value: object) -> tuple[object, ...]:
|
||||
"""The elements of a plural field, whatever `asdict` rebuilt it as.
|
||||
|
||||
`asdict` reconstructs a tuple field as a tuple of the same members, but hands
|
||||
it back inside an untyped dict, so the elements are re-declared as plain
|
||||
objects here and converted by `_scalar` like any other value.
|
||||
"""
|
||||
return cast("tuple[object, ...]", value) if isinstance(value, tuple) else ()
|
||||
_REPEATED: Final[dict[str, str]] = {"providers": "provider", "models": "model", "capabilities": "capability"}
|
||||
|
||||
|
||||
def _declared_subject(args: tuple[object, ...]) -> Subject | None:
|
||||
|
|
@ -366,10 +391,13 @@ def _declared_subject(args: tuple[object, ...]) -> Subject | None:
|
|||
|
||||
|
||||
def subject_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]:
|
||||
"""The declared half, in dataclass field order. Empty fields emit nothing;
|
||||
the emitter is what guarantees every key exists in the JSON."""
|
||||
from dataclasses import asdict # local: keeps module import trivially cheap
|
||||
"""The declared half, in dataclass field order.
|
||||
|
||||
`_REPEATED` is the only field-specific knowledge here: which fields are
|
||||
plural, and the SINGULAR name their repeated <property> goes out under. A new
|
||||
scalar field needs no edit. Empty fields emit nothing; the emitter is what
|
||||
guarantees every key exists in the JSON, with `providers` and `models` as
|
||||
`[]` when nothing was declared."""
|
||||
marker = item.get_closest_marker("meta")
|
||||
if marker is None:
|
||||
return ()
|
||||
|
|
@ -381,12 +409,13 @@ def subject_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]:
|
|||
for name, value in fields.items():
|
||||
repeated = _REPEATED.get(name)
|
||||
if repeated is not None:
|
||||
pairs.extend((repeated, _scalar(member)) for member in _members(value))
|
||||
pairs.extend((repeated, _scalar(member)) for member in _members(value) or ())
|
||||
elif value is not None and value != "":
|
||||
pairs.append((name, _scalar(value)))
|
||||
return tuple(pairs)
|
||||
|
||||
|
||||
def step_properties() -> tuple[tuple[str, str], ...]:
|
||||
"""The recorded half. Appended after the call phase, never at collection."""
|
||||
"""The recorded half. Appended after the setup and call phases, never at
|
||||
collection."""
|
||||
return tuple(("step", label) for label in STEPS.taken())
|
||||
|
|
|
|||
|
|
@ -118,17 +118,18 @@ def attach_result_properties(item: pytest.Item) -> None:
|
|||
|
||||
|
||||
def attach_step_properties(item: pytest.Item) -> None:
|
||||
"""Attach the runtime-recorded steps after the call phase.
|
||||
"""Attach the runtime-recorded steps; called after setup and after call.
|
||||
|
||||
Separate from `attach_result_properties` because it cannot share its home:
|
||||
that one runs in `pytest_collection_modifyitems`, before any test body has
|
||||
executed, so the recorder is necessarily empty there.
|
||||
|
||||
Any `step` entries already on the item are dropped first. The suite runs with
|
||||
`--reruns 1`, so a flaky test's second attempt would otherwise append a second
|
||||
copy of the story behind the first, and the report would read as one very long
|
||||
test that did everything twice. Last attempt wins, which is the attempt whose
|
||||
outcome JUnit records.
|
||||
Any `step` entries already on the item are dropped first, which is what makes
|
||||
the second call of a test safe: the story attached after setup is replaced by
|
||||
the longer one attached after call. It also covers `--reruns 1`, where a flaky
|
||||
test's second attempt would otherwise append a second copy of the story behind
|
||||
the first, and the report would read as one very long test that did everything
|
||||
twice. Last attempt wins, which is the attempt whose outcome JUnit records.
|
||||
"""
|
||||
item.user_properties[:] = [entry for entry in item.user_properties if entry[0] != "step"]
|
||||
item.user_properties.extend(step_properties())
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
addopts = --strict-markers --strict-config --reruns 1 --only-rerun "kind='network'" --only-rerun "status_code=5[0-9][0-9]"
|
||||
markers =
|
||||
e2e: live test that requires a running proxy and real provider keys
|
||||
meta: typed e2e_metadata.Subject describing what this test drives (domain/route/provider/model/capabilities/mode); attach it with @meta(Subject(...)), never as a bare pytest.mark
|
||||
meta: typed e2e_metadata.Subject describing what this test drives (domain/route/providers/models/capabilities/mode); attach it with @meta(Subject(...)), never as a bare pytest.mark
|
||||
replayable: edge-wired test whose provider traffic replays from a fixture bundle, so it makes zero provider calls in replay mode; the record/replay CI lane selects it with -m replayable
|
||||
load: heavy throughput/load test; collected last so it never perturbs latency-sensitive suites
|
||||
weekly: real-provider anomaly load test that spends real money; deselected unless E2E_WEEKLY_ANOMALY is set
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -77,8 +77,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -102,8 +102,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -145,8 +145,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -166,8 +166,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -189,8 +189,8 @@ class TestBudgetBlocksPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -225,8 +225,8 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -248,8 +248,8 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -269,8 +269,8 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ FALLBACK_MODEL = "gpt-5.5"
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.MESSAGES,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=PRIMARY_MODEL,
|
||||
providers=(Provider.ANTHROPIC, Provider.OPENAI),
|
||||
models=(PRIMARY_MODEL, FALLBACK_MODEL),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ def test_key_with_budget_duration_schedules_reset_at_creation(client: BudgetClie
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -123,8 +123,8 @@ def test_key_spend_blocks_at_cap(client: BudgetClient, resources: ResourceManage
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -168,8 +168,8 @@ def test_key_budget_reset_at_advances_after_window(client: BudgetClient, resourc
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -221,8 +221,8 @@ def test_multi_window_key_resets_each_window_independently(client: BudgetClient,
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -263,8 +263,8 @@ def test_team_member_budget_reset_at_advances(client: BudgetClient, resources: R
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ class TestBudgetResetPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -71,8 +71,8 @@ class TestBudgetResetPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -92,8 +92,8 @@ class TestBudgetResetPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -123,8 +123,8 @@ class TestBudgetResetPerLevel:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -150,8 +150,8 @@ class TestKeyBudgetResetAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -169,8 +169,8 @@ class TestKeyBudgetResetAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -188,8 +188,8 @@ class TestKeyBudgetResetAcrossKeyKinds:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ class TestModelAccessGroupBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -128,8 +128,8 @@ class TestModelAccessGroupBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -150,8 +150,8 @@ class TestModelAccessGroupBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -173,8 +173,8 @@ class TestModelAccessGroupBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.BUDGET_MANAGEMENT,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
)
|
||||
)
|
||||
def test_the_budget_read_reports_the_spend_drawn_against_the_pool(
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ def _call(client: BudgetClient, key: str, model: str):
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CAPPED_MODEL,
|
||||
providers=(Provider.ANTHROPIC, Provider.GEMINI),
|
||||
models=(CAPPED_MODEL, FREE_MODEL),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -75,8 +75,8 @@ def test_model_max_budget_isolates_per_model(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model=FREE_MODEL,
|
||||
providers=(Provider.GEMINI,),
|
||||
models=(FREE_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ def _drive_to_block(client: BudgetClient, key: str) -> StreamingResponse:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=CHEAP_OPENAI_MODEL,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(CHEAP_OPENAI_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -104,8 +104,8 @@ def test_short_window_blocks_then_resets(client: BudgetClient, resources: Resour
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=CHEAP_OPENAI_MODEL,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(CHEAP_OPENAI_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ pytestmark = pytest.mark.e2e
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ def _accumulate(client: BudgetClient, key: str, count: int) -> None:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ def _tagged_call(client: BudgetClient, key: str, tag: str):
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ class TestTeamMemberBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -112,8 +112,8 @@ class TestTeamMemberBudget:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@ class TestTeamMemberBudgetIsolation:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=MODEL,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ def _as_datetime(value: str) -> datetime:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ def _drive_to_block(client: BudgetClient, key: str) -> StreamingResponse:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -99,8 +99,8 @@ def test_team_short_window_blocks_then_resets(client: BudgetClient, resources: R
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model="claude-haiku-4-5",
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=("claude-haiku-4-5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ class TestUserBudgetAcrossKeys:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=MODEL,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -162,8 +162,8 @@ class TestDynamicRateLimitPriority:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=BACKEND,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -213,8 +213,8 @@ class TestDynamicRateLimitPriority:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=BACKEND,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@ class TestKeyRateLimits:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CHEAP_ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(CHEAP_ANTHROPIC_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -202,8 +202,8 @@ class TestKeyRateLimits:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CHEAP_ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(CHEAP_ANTHROPIC_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -230,8 +230,8 @@ class TestKeyRateLimits:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CHEAP_ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(CHEAP_ANTHROPIC_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -264,8 +264,8 @@ class TestKeyRateLimits:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=CHEAP_ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(CHEAP_ANTHROPIC_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ class TestRedisBackedRateLimit:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=BACKEND,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ class TestRedisCircuitBreakerPath:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=BACKEND,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -106,8 +106,8 @@ class TestTpmExcludesCachedTokens:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.ANTHROPIC,
|
||||
model=ANTHROPIC_MODEL,
|
||||
providers=(Provider.ANTHROPIC,),
|
||||
models=(ANTHROPIC_MODEL,),
|
||||
capabilities=(Capability.PROMPT_CACHING,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ class TestCacheCostAccounting:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=CACHE_WRITE_BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(CACHE_WRITE_BACKEND,),
|
||||
capabilities=(Capability.PROMPT_CACHING,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
@ -167,8 +167,8 @@ class TestCacheCostAccounting:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=CACHE_WRITE_BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(CACHE_READ_BACKEND,),
|
||||
capabilities=(Capability.PROMPT_CACHING, Capability.REASONING),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
@ -241,8 +241,8 @@ class TestCacheCostAccounting:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=CACHE_READ_BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(CACHE_READ_BACKEND,),
|
||||
capabilities=(Capability.PROMPT_CACHING,),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
|
|
@ -282,8 +282,8 @@ class TestCacheCostAccounting:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.MESSAGES,
|
||||
provider=Provider.OPENAI,
|
||||
model=BRIDGE_BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BRIDGE_BACKEND,),
|
||||
capabilities=(Capability.PROMPT_CACHING,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ class TestCostHeaders:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ EMBED_MODEL: Final = "openai-text-embedding-3-small"
|
|||
BATCH_MODEL: Final = "openai-gpt-4o-mini"
|
||||
BATCH_BACKEND_MODEL: Final = "gpt-4o-mini"
|
||||
BATCH_PROVIDER: Final = "openai"
|
||||
DRIVEN_MODELS: Final = (CHAT_MODEL, MESSAGES_MODEL, RESPONSES_MODEL, EMBED_MODEL, BATCH_MODEL)
|
||||
HEALTH_SERVICE_ACCOUNT: Final = "litellm-internal-health-check"
|
||||
BATCH_TERMINAL_STATUSES: Final = frozenset({"completed", "failed", "cancelled", "expired"})
|
||||
FAILED_BATCH_POLL_SECONDS: Final = 120.0
|
||||
|
|
@ -286,6 +287,8 @@ class TestKeyAttribution:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
providers=(Provider.GEMINI, Provider.ANTHROPIC, Provider.OPENAI),
|
||||
models=DRIVEN_MODELS,
|
||||
)
|
||||
)
|
||||
def test_every_write_path_row_joins_the_key(self, client: SpendClient, driven: DrivenKey) -> None:
|
||||
|
|
@ -328,6 +331,8 @@ class TestKeyAttribution:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
providers=(Provider.GEMINI, Provider.ANTHROPIC, Provider.OPENAI),
|
||||
models=DRIVEN_MODELS,
|
||||
)
|
||||
)
|
||||
def test_spend_logs_by_key_return_every_row_with_the_alias(self, client: SpendClient, driven: DrivenKey) -> None:
|
||||
|
|
@ -362,6 +367,8 @@ class TestKeyAttribution:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
providers=(Provider.GEMINI, Provider.ANTHROPIC, Provider.OPENAI),
|
||||
models=DRIVEN_MODELS,
|
||||
)
|
||||
)
|
||||
def test_user_daily_activity_reports_alias_and_email(self, client: SpendClient, driven: DrivenKey) -> None:
|
||||
|
|
@ -390,6 +397,8 @@ class TestKeyAttribution:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.HEALTH,
|
||||
providers=(Provider.GEMINI,),
|
||||
models=(CHAT_MODEL,),
|
||||
)
|
||||
)
|
||||
def test_health_check_rows_keep_the_service_account_key(self, client: SpendClient) -> None:
|
||||
|
|
@ -409,8 +418,8 @@ class TestKeyAttribution:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.BATCHES,
|
||||
provider=Provider.OPENAI,
|
||||
model=BATCH_MODEL,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BATCH_MODEL,),
|
||||
mode=Mode.BATCH,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ pytestmark = [pytest.mark.e2e, pytest.mark.replayable]
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=f"openai/{CHEAP_OPENAI_MODEL}",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(f"openai/{CHEAP_OPENAI_MODEL}",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ class TestServiceTierPricing:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model=BACKEND,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND,),
|
||||
capabilities=(Capability.REASONING,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ def _require_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -111,8 +111,8 @@ def test_chat_completion_writes_nonzero_spend_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -148,8 +148,8 @@ def test_streaming_chat_completion_tracks_spend(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.MESSAGES,
|
||||
provider=Provider.OPENAI,
|
||||
model="openai-responses-codex",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=("openai-responses-codex",),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -227,8 +227,8 @@ def test_streaming_messages_via_responses_bridge_tracks_spend(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.EMBEDDINGS,
|
||||
provider=Provider.OPENAI,
|
||||
model="openai-text-embedding-3-small",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=("openai-text-embedding-3-small",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -259,8 +259,8 @@ def test_embedding_writes_nonzero_spend_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -304,8 +304,8 @@ def test_cache_hit_is_zero_cost_and_suffixed(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -341,8 +341,8 @@ def test_key_spend_equals_sum_of_logs(client: SpendClient, scoped_key: str) -> N
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model="openai/gpt-5.6-luna",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=("openai/gpt-5.6-luna",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -367,8 +367,8 @@ def test_burst_of_concurrent_calls_loses_no_spend(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -429,8 +429,8 @@ def test_spend_logs_v2_pagination_caps_pages_and_keeps_total(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -455,8 +455,8 @@ def test_request_tags_round_trip(client: SpendClient, scoped_key: str) -> None:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -502,8 +502,8 @@ def test_tag_spend_matches_sum_of_tagged_logs(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -529,8 +529,8 @@ def test_end_user_spend_attributed_on_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI, Provider.ANTHROPIC),
|
||||
models=("gemini-2.5-flash", "claude-haiku-4-5"),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -587,8 +587,8 @@ def test_each_model_on_a_shared_key_gets_its_own_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.OPENAI,
|
||||
model="openai/gpt-5.5",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=("openai/gpt-5.5",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
@ -621,8 +621,8 @@ def test_failure_call_writes_failure_status_row(
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
)
|
||||
)
|
||||
def test_spend_calculate_returns_nonzero_cost(client: SpendClient) -> None:
|
||||
|
|
@ -639,8 +639,8 @@ def test_spend_calculate_returns_nonzero_cost(client: SpendClient) -> None:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
provider=Provider.GEMINI,
|
||||
model="gemini-2.5-flash",
|
||||
providers=(Provider.GEMINI,),
|
||||
models=("gemini-2.5-flash",),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ class TestTeamDailyActivity:
|
|||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
provider=Provider.OPENAI,
|
||||
model="openai/gpt-5.6-luna",
|
||||
providers=(Provider.OPENAI,),
|
||||
models=("openai/gpt-5.6-luna",),
|
||||
)
|
||||
)
|
||||
def test_valid_date_range_returns_results_and_metadata(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ rollups and, for ``source``, the status page's per-test links to GitHub.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
from dataclasses import fields, replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -169,15 +169,16 @@ class TestSubjectProperties:
|
|||
def test_every_declared_field_becomes_a_property_in_field_order(self, request: pytest.FixtureRequest) -> None:
|
||||
"""One pass over `dataclasses.asdict`: declaration order is emission order,
|
||||
a (str, Enum) member is written as its `.value` and never `str(member)`,
|
||||
the plural field emits a repeated SINGULAR `capability`, and an unset field
|
||||
(`provider` here) emits nothing at all."""
|
||||
and every plural field emits a repeated SINGULAR name (`provider`, `model`,
|
||||
`capability`), one <property> per member and never a delimiter-joined value."""
|
||||
test = type(self).test_every_declared_field_becomes_a_property_in_field_order
|
||||
request.applymarker(
|
||||
meta(
|
||||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.CHAT_COMPLETIONS,
|
||||
model="gpt-5.5",
|
||||
providers=(Provider.GEMINI, Provider.ANTHROPIC),
|
||||
models=("gemini-2.5-flash", "claude-haiku-4-5"),
|
||||
capabilities=(Capability.VISION, Capability.FUNCTION_CALLING, Capability.VISION),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
|
|
@ -186,30 +187,108 @@ class TestSubjectProperties:
|
|||
assert subject_properties(collected_item(request, test.__name__)) == (
|
||||
("domain", "spend-budgets"),
|
||||
("route", "chat_completions"),
|
||||
("model", "gpt-5.5"),
|
||||
("provider", "anthropic"),
|
||||
("provider", "gemini"),
|
||||
("model", "claude-haiku-4-5"),
|
||||
("model", "gemini-2.5-flash"),
|
||||
("capability", "function_calling"),
|
||||
("capability", "vision"),
|
||||
("mode", "nonstream"),
|
||||
)
|
||||
|
||||
def test_one_provider_with_three_models_pairs_nothing(self, request: pytest.FixtureRequest) -> None:
|
||||
"""The claude_code matrix shape: one test node drives haiku, sonnet and opus
|
||||
through a single provider. The two lists are independent sets, so their
|
||||
lengths need not agree and no model is tied to a provider by position."""
|
||||
test = type(self).test_one_provider_with_three_models_pairs_nothing
|
||||
request.applymarker(
|
||||
meta(
|
||||
Subject(
|
||||
providers=(Provider.BEDROCK,),
|
||||
models=("claude-sonnet-4-5", "claude-opus-4-7", "claude-haiku-4-5"),
|
||||
)
|
||||
)
|
||||
)
|
||||
assert subject_properties(collected_item(request, test.__name__)) == (
|
||||
("provider", "bedrock"),
|
||||
("model", "claude-haiku-4-5"),
|
||||
("model", "claude-opus-4-7"),
|
||||
("model", "claude-sonnet-4-5"),
|
||||
)
|
||||
|
||||
def test_an_empty_plural_field_emits_nothing(self, request: pytest.FixtureRequest) -> None:
|
||||
"""No `provider`, `model` or `capability` property at all, rather than one
|
||||
with an empty value: the emitter is what turns absence into `[]`."""
|
||||
test = type(self).test_an_empty_plural_field_emits_nothing
|
||||
request.applymarker(meta(Subject(domain=Domain.MANAGEMENT)))
|
||||
assert subject_properties(collected_item(request, test.__name__)) == (("domain", "management"),)
|
||||
|
||||
def test_scalar_property_names_are_the_dataclass_field_names(self, request: pytest.FixtureRequest) -> None:
|
||||
"""The mapping is `asdict`, not a hand-written table: a scalar field added
|
||||
to `Subject` later serializes under its own name with no edit to the
|
||||
serializer. Proven by reading the field list back off the dataclass."""
|
||||
test = type(self).test_scalar_property_names_are_the_dataclass_field_names
|
||||
request.applymarker(meta(Subject(domain=Domain.UNKNOWN, route=Route.HEALTH, model="m", mode=Mode.STREAM)))
|
||||
request.applymarker(meta(Subject(domain=Domain.UNKNOWN, route=Route.HEALTH, mode=Mode.STREAM)))
|
||||
declared = tuple(field.name for field in fields(Subject))
|
||||
emitted = tuple(name for name, _ in subject_properties(collected_item(request, test.__name__)))
|
||||
assert emitted == tuple(name for name in declared if name in {"domain", "route", "model", "mode"})
|
||||
assert emitted == tuple(name for name in declared if name in {"domain", "route", "mode"})
|
||||
|
||||
def test_capabilities_are_deduped_and_sorted_at_declaration(self) -> None:
|
||||
def test_every_plural_field_is_deduped_and_sorted_at_declaration(self) -> None:
|
||||
"""Canonicalized in `__post_init__`, so two tests that spelled the same set
|
||||
in different orders produce byte-identical properties and the committed run
|
||||
files diff cleanly."""
|
||||
assert Subject(capabilities=(Capability.VISION, Capability.REASONING, Capability.VISION)).capabilities == (
|
||||
Capability.REASONING,
|
||||
Capability.VISION,
|
||||
files diff cleanly. Sorted by the value that is serialized, which for an
|
||||
enum is its `.value` and not its member name."""
|
||||
subject = Subject(
|
||||
providers=(Provider.OPENAI, Provider.ANTHROPIC, Provider.OPENAI),
|
||||
models=("gpt-5.5", "claude-haiku-4-5", "gpt-5.5"),
|
||||
capabilities=(Capability.VISION, Capability.REASONING, Capability.VISION),
|
||||
)
|
||||
assert subject.providers == (Provider.ANTHROPIC, Provider.OPENAI)
|
||||
assert subject.models == ("claude-haiku-4-5", "gpt-5.5")
|
||||
assert subject.capabilities == (Capability.REASONING, Capability.VISION)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("models", "gpt-5.5"),
|
||||
("models", ["gpt-5.5"]),
|
||||
("providers", Provider.OPENAI),
|
||||
("providers", [Provider.OPENAI]),
|
||||
("capabilities", Capability.VISION),
|
||||
("capabilities", frozenset({Capability.VISION})),
|
||||
],
|
||||
)
|
||||
def test_a_plural_field_refuses_anything_but_a_tuple(self, field: str, value: object) -> None:
|
||||
"""`models=("gpt-5.5")` is a str, not a one-member tuple: the parentheses
|
||||
do nothing without the trailing comma, and iterating the str would declare
|
||||
one model per character. basedpyright flags it at the call site; this is
|
||||
the runtime half, raised where the decorator runs, so it lands as a
|
||||
collection error naming the file. `replace` is the untyped way in, since
|
||||
the typed constructor would not let the test spell the mistake."""
|
||||
with pytest.raises(TypeError, match=rf"Subject\.{field} must be a tuple"):
|
||||
_ = replace(Subject(), **{field: value})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "member_type"),
|
||||
[
|
||||
("providers", ("openai",), "Provider"),
|
||||
("capabilities", ("vision",), "Capability"),
|
||||
("models", (5,), "str"),
|
||||
],
|
||||
)
|
||||
def test_a_plural_field_refuses_a_member_of_the_wrong_type(
|
||||
self, field: str, value: object, member_type: str
|
||||
) -> None:
|
||||
"""A bare "openai" where `Provider.OPENAI` belongs would serialize fine
|
||||
today and stop joining the day the enum value is renamed."""
|
||||
with pytest.raises(TypeError, match=rf"Subject\.{field} takes {member_type} members"):
|
||||
_ = replace(Subject(), **{field: value})
|
||||
|
||||
def test_a_blank_model_is_dropped_rather_than_refused(self) -> None:
|
||||
"""`models` is fed from env-overridable constants. A blank override is the
|
||||
operator's mistake, and it must cost one missing property, not the
|
||||
collection of the whole module."""
|
||||
assert Subject(models=("", "gpt-5.5")).models == ("gpt-5.5",)
|
||||
|
||||
def test_the_typed_marker_only_ever_appends_to_the_fixed_prefix(self, request: pytest.FixtureRequest) -> None:
|
||||
"""Loki, Grafana and the status page read `package`/`covers`/`source`; the
|
||||
|
|
@ -267,9 +346,6 @@ class TestProviderMirrorsLitellm:
|
|||
needs it, but a value that is not a provider at all would ship a property
|
||||
no consumer can join on."""
|
||||
try:
|
||||
# Local, and the only litellm import under tests/e2e: at module scope
|
||||
# it would be exactly the collection-time dependency `Provider` exists
|
||||
# to avoid.
|
||||
from litellm.types.utils import LlmProviders
|
||||
except ImportError: # pragma: no cover - the runner image's shape
|
||||
pytest.skip("litellm is not importable here, which is the property under test")
|
||||
|
|
@ -282,9 +358,9 @@ class TestStepRecording:
|
|||
"""The recorded half: `@step`-decorated harness helpers append to the running
|
||||
test's story as they execute.
|
||||
|
||||
Each test here starts from an empty log because conftest's autouse
|
||||
`_record_steps` fixture resets the recorder at setup -- the same reset the
|
||||
live suite relies on for per-test isolation.
|
||||
Each test here starts from an empty log because conftest's
|
||||
`pytest_runtest_setup` hook resets the recorder first thing in every test's
|
||||
setup -- the same reset the live suite relies on for per-test isolation.
|
||||
"""
|
||||
|
||||
def test_steps_land_in_call_order(self) -> None:
|
||||
|
|
|
|||
346
tests/e2e/test_junit_report.py
Normal file
346
tests/e2e/test_junit_report.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""The JUnit report itself, written by a real pytest run.
|
||||
|
||||
No proxy and no ``e2e`` marker. test_junit_properties.py pins the functions that
|
||||
build the properties; this pins what reaches the XML once pytest, its junitxml
|
||||
plugin, pytest-rerunfailures and xdist are all in the loop. Each case writes a
|
||||
throwaway suite into a tmp dir and runs it in a child interpreter with THIS
|
||||
directory's conftest.py loaded as a plugin, so the hooks under test are the ones
|
||||
the live suite runs and the recorder is the real one, never a copy of either.
|
||||
|
||||
The timing that makes the recorded half work is pytest's, which is why it is
|
||||
pinned here against the real thing: junitxml writes a testcase's properties from
|
||||
its TEARDOWN report, and pytest builds that report from ``item.user_properties``
|
||||
after the setup and call phases have both attached the steps. The suite runs
|
||||
distributed, so every assertion is made in-process and again under ``-n 2``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
|
||||
SUITE_DIR: Final = Path(__file__).resolve().parent
|
||||
CHILD_TIMEOUT_SECONDS: Final = 180
|
||||
|
||||
STORY_SUITE: Final = """
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta, step
|
||||
|
||||
FIRST_ATTEMPT_MADE = Path(__file__).with_name("first-attempt-made")
|
||||
|
||||
|
||||
@step("generate virtual key")
|
||||
def generate_key() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@step("create team")
|
||||
def create_team() -> None:
|
||||
raise RuntimeError("/team/new answered 500")
|
||||
|
||||
|
||||
@step("POST /chat/completions")
|
||||
def chat(*, ok: bool) -> None:
|
||||
if not ok:
|
||||
raise AssertionError("status_code=502 from upstream")
|
||||
|
||||
|
||||
@step("poll /spend/logs")
|
||||
def poll_spend_logs() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@step("delete virtual key")
|
||||
def delete_key() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key() -> Iterator[None]:
|
||||
generate_key()
|
||||
yield
|
||||
delete_key()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team(key: None) -> None:
|
||||
create_team()
|
||||
|
||||
|
||||
def test_passes(key: None) -> None:
|
||||
chat(ok=True)
|
||||
poll_spend_logs()
|
||||
|
||||
|
||||
def test_fails(key: None) -> None:
|
||||
chat(ok=False)
|
||||
poll_spend_logs()
|
||||
|
||||
|
||||
def test_errors_in_setup(team: None) -> None:
|
||||
poll_spend_logs()
|
||||
|
||||
|
||||
def test_passes_on_the_rerun(key: None) -> None:
|
||||
first_attempt = not FIRST_ATTEMPT_MADE.exists()
|
||||
FIRST_ATTEMPT_MADE.touch()
|
||||
chat(ok=not first_attempt)
|
||||
poll_spend_logs()
|
||||
|
||||
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.BEDROCK, Provider.ANTHROPIC),
|
||||
models=("claude-sonnet-4-5", "claude-opus-4-7", "claude-haiku-4-5"),
|
||||
capabilities=(Capability.VISION, Capability.FUNCTION_CALLING),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
)
|
||||
def test_declares_two_providers_and_three_models() -> None:
|
||||
assert Provider.BEDROCK.value == "bedrock"
|
||||
"""
|
||||
|
||||
WIDE_FINALIZER_SUITE: Final = """
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from e2e_metadata import step
|
||||
|
||||
|
||||
@step("generate virtual key")
|
||||
def generate_key() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@step("delete shared team")
|
||||
def delete_shared_team() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shared_team() -> Iterator[None]:
|
||||
yield
|
||||
delete_shared_team()
|
||||
|
||||
|
||||
def test_uses_the_shared_team(shared_team: None) -> None:
|
||||
generate_key()
|
||||
"""
|
||||
|
||||
WIDE_SETUP_ERROR_SUITE: Final = """
|
||||
import pytest
|
||||
from e2e_metadata import step
|
||||
|
||||
|
||||
@step("log in to the identity provider")
|
||||
def log_in() -> None:
|
||||
raise RuntimeError("identity provider is down")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity() -> None:
|
||||
log_in()
|
||||
|
||||
|
||||
def test_dies_in_a_module_scoped_fixture(identity: None) -> None:
|
||||
assert identity is None
|
||||
"""
|
||||
|
||||
BARE_STR_SUITE: Final = """
|
||||
from e2e_metadata import Subject, meta
|
||||
|
||||
|
||||
@meta(Subject(models=("gpt-5.5")))
|
||||
def test_never_collected() -> None:
|
||||
assert Subject is not None
|
||||
"""
|
||||
|
||||
Properties = tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
def write_suite(directory: Path, modules: Mapping[str, str]) -> None:
|
||||
"""Lay a child suite out in ``directory``, with an ini file of its own.
|
||||
|
||||
The ini pins the child's rootdir to the tmp dir wherever that lives, and its
|
||||
``pythonpath`` is what makes this directory's conftest.py, and the harness
|
||||
modules the child suite imports, importable under ``-I``.
|
||||
"""
|
||||
_ = (directory / "pytest.ini").write_text(f"[pytest]\npythonpath = {shlex.quote(str(SUITE_DIR))}\n")
|
||||
for name, source in modules.items():
|
||||
_ = (directory / name).write_text(source)
|
||||
|
||||
|
||||
def run_child_pytest(suite: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Run pytest over ``suite`` in a fresh interpreter, hooked up like the live suite.
|
||||
|
||||
``-p conftest`` registers this directory's conftest.py as a plugin, since a
|
||||
tmp dir outside tests/e2e would never pick it up by location. The parent's
|
||||
fixture-mode and addopts settings are dropped so a replay lane cannot leak
|
||||
into the child.
|
||||
"""
|
||||
inherited: Final = {
|
||||
name: value
|
||||
for name, value in os.environ.items()
|
||||
if name != "PYTEST_ADDOPTS" and not name.startswith("E2E_FIXTURE_")
|
||||
}
|
||||
return subprocess.run(
|
||||
[sys.executable, "-I", "-m", "pytest", "-p", "conftest", "-p", "no:cacheprovider", *args, str(suite)],
|
||||
cwd=suite,
|
||||
env=inherited,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=CHILD_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def properties_by_test(testsuite: ElementTree.Element) -> Mapping[str, Properties]:
|
||||
"""Every testcase's <property> pairs, in document order, keyed by test name."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
testcase.get("name", ""): tuple(
|
||||
(prop.get("name", ""), prop.get("value", "")) for prop in testcase.iter("property")
|
||||
)
|
||||
for testcase in testsuite.iter("testcase")
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def values(properties: Properties, name: str) -> tuple[str, ...]:
|
||||
return tuple(value for prop, value in properties if prop == name)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="module",
|
||||
params=[
|
||||
pytest.param((), id="in-process"),
|
||||
pytest.param(
|
||||
("-n", "2"),
|
||||
id="xdist",
|
||||
marks=pytest.mark.skipif(find_spec("xdist") is None, reason="pytest-xdist is not installed"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def report(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Mapping[str, Properties]:
|
||||
"""One child run per distribution mode, shared by every assertion below.
|
||||
|
||||
``--reruns 1`` and the ``--only-rerun`` pattern are the live suite's own
|
||||
addopts. The two wide-scope modules sort ahead of the story, and next to each
|
||||
other, so in-process the second one's setup runs right after the first one's
|
||||
module-scoped finalizer.
|
||||
"""
|
||||
distribution: Final[tuple[str, ...]] = request.param # pyright: ignore[reportAny] # pytest types request.param as Any
|
||||
suite: Final = tmp_path_factory.mktemp("suite")
|
||||
write_suite(
|
||||
suite,
|
||||
{
|
||||
"test_scope_a_finalizer.py": WIDE_FINALIZER_SUITE,
|
||||
"test_scope_b_setup_error.py": WIDE_SETUP_ERROR_SUITE,
|
||||
"test_story.py": STORY_SUITE,
|
||||
},
|
||||
)
|
||||
xml: Final = suite / "report.xml"
|
||||
child: Final = run_child_pytest(
|
||||
suite, f"--junitxml={xml}", "--reruns", "1", "--only-rerun", "status_code=5[0-9][0-9]", *distribution
|
||||
)
|
||||
assert xml.exists(), f"the child run wrote no JUnit report:\n{child.stdout}\n{child.stderr}"
|
||||
testsuite: Final = next(ElementTree.parse(xml).getroot().iter("testsuite"))
|
||||
outcomes: Final = {name: testsuite.get(name) for name in ("tests", "failures", "errors", "skipped")}
|
||||
assert outcomes == {"tests": "7", "failures": "1", "errors": "2", "skipped": "0"}, child.stdout
|
||||
return properties_by_test(testsuite)
|
||||
|
||||
|
||||
class TestStepsReachTheReport:
|
||||
def test_a_passing_test_tells_its_story_in_call_order(self, report: Mapping[str, Properties]) -> None:
|
||||
"""Fixture setup first, then the body. The finalizer's "delete virtual key"
|
||||
is cleanup and is deliberately not part of the story."""
|
||||
assert values(report["test_passes"], "step") == (
|
||||
"generate virtual key",
|
||||
"POST /chat/completions",
|
||||
"poll /spend/logs",
|
||||
)
|
||||
|
||||
def test_a_failing_test_s_last_step_is_where_it_died(self, report: Mapping[str, Properties]) -> None:
|
||||
"""The reason the field exists. Nothing the test never reached is listed,
|
||||
and no teardown step is appended behind the one it died on."""
|
||||
assert values(report["test_fails"], "step") == ("generate virtual key", "POST /chat/completions")
|
||||
|
||||
def test_a_setup_error_keeps_the_steps_recorded_before_the_crash(self, report: Mapping[str, Properties]) -> None:
|
||||
"""A fixture that raises never reaches the call phase, and setup is where
|
||||
an e2e test most often dies (proxy not ready, key creation failing), so
|
||||
the steps have to be attached after setup too."""
|
||||
assert values(report["test_errors_in_setup"], "step") == ("generate virtual key", "create team")
|
||||
|
||||
def test_a_rerun_reports_only_the_attempt_junit_records(self, report: Mapping[str, Properties]) -> None:
|
||||
"""The first attempt died on the chat call and the rerun got through. Steps
|
||||
are attached twice per attempt, and none of that may show up as a doubled
|
||||
or a stale story."""
|
||||
assert values(report["test_passes_on_the_rerun"], "step") == (
|
||||
"generate virtual key",
|
||||
"POST /chat/completions",
|
||||
"poll /spend/logs",
|
||||
)
|
||||
|
||||
def test_a_setup_error_does_not_inherit_a_wider_finalizer_s_steps(self, report: Mapping[str, Properties]) -> None:
|
||||
"""A module-scoped finalizer runs after the last test of its module, and
|
||||
a module-scoped fixture is set up before any function-scoped one. The log
|
||||
is emptied ahead of both, so the next test's setup error reports its own
|
||||
steps and not "delete shared team"."""
|
||||
assert values(report["test_uses_the_shared_team"], "step") == ("generate virtual key",)
|
||||
assert values(report["test_dies_in_a_module_scoped_fixture"], "step") == ("log in to the identity provider",)
|
||||
|
||||
def test_steps_ride_behind_the_fixed_prefix(self, report: Mapping[str, Properties]) -> None:
|
||||
"""`package`/`covers`/`source` are what Loki, Grafana and the status page
|
||||
already read, on every outcome including a setup error."""
|
||||
for name in ("test_passes", "test_fails", "test_errors_in_setup"):
|
||||
assert tuple(prop for prop, _ in report[name])[:4] == ("package", "covers", "source", "step"), name
|
||||
|
||||
|
||||
class TestDeclaredPropertiesReachTheReport:
|
||||
def test_repeated_provider_model_and_capability_round_trip(self, report: Mapping[str, Properties]) -> None:
|
||||
"""One <property> per member under the SINGULAR name, deduped and sorted,
|
||||
with no pairing between the two providers and the three models."""
|
||||
declared: Final = tuple(
|
||||
(prop, value)
|
||||
for prop, value in report["test_declares_two_providers_and_three_models"]
|
||||
if prop not in {"package", "covers", "source"}
|
||||
)
|
||||
assert declared == (
|
||||
("domain", "llm-translation"),
|
||||
("route", "messages"),
|
||||
("provider", "anthropic"),
|
||||
("provider", "bedrock"),
|
||||
("model", "claude-haiku-4-5"),
|
||||
("model", "claude-opus-4-7"),
|
||||
("model", "claude-sonnet-4-5"),
|
||||
("capability", "function_calling"),
|
||||
("capability", "vision"),
|
||||
("mode", "stream"),
|
||||
)
|
||||
|
||||
|
||||
class TestBareStrIsACollectionError:
|
||||
def test_a_str_where_a_tuple_belongs_fails_collection_and_names_the_fix(self, tmp_path: Path) -> None:
|
||||
"""`models=("gpt-5.5")` raises where the decorator runs, which is import, so
|
||||
pytest stops at collection and points at the file. Nothing is run and no
|
||||
one-letter `model` properties are ever shipped."""
|
||||
write_suite(tmp_path, {"test_bare_str.py": BARE_STR_SUITE})
|
||||
child: Final = run_child_pytest(tmp_path)
|
||||
assert child.returncode == pytest.ExitCode.INTERRUPTED, child.stdout
|
||||
assert "Subject.models must be a tuple, got str: 'gpt-5.5'" in child.stdout
|
||||
assert "models=(x,), not models=(x)" in child.stdout
|
||||
Loading…
Add table
Reference in a new issue