mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
A test's JUnit report says whether it passed, never what it did or where a failing test died. This records that from the harness, so nothing about it is hand-written and it cannot drift from what the test actually ran
`@step("create team with a budget")` from the new tests/e2e/e2e_metadata.py goes on harness helpers, never on tests, and appends its label to the running test's step log in call order. The label is recorded before the wrapped call, so a helper that raises still leaves its own label last: a failing test's last step is where it died. Every public harness method that performs an action now carries one, 355 across the client modules, lifecycle, idp, the logging readers, migrations and the claude_code driver
Only the outermost step records, tracked per thread. Harness layers call each other (ResourceManager.key goes through ProxyClient.generate_key, a domain client wraps the shared ProxyClient), so every layer carries a label and the story still reads at the level the test called in at, one beat per action. A step above @contextmanager holds the guard through __enter__ and __exit__, so a context's cleanup never lands behind the step a test died on, and a bare generator function is refused at import because its body interleaves with its caller's. Consecutive duplicates collapse and the log caps at 50, so a poll loop is one beat rather than fifty. The wrapper is a frame, so the eight cleanup and retry warnings raised directly inside decorated helpers use stacklevel=2 + STEP_FRAMES to keep reporting at their caller
The log is emptied first thing in pytest_runtest_setup and attached from the existing pytest_runtest_makereport wrapper after setup and again after call, so a test that errors in a fixture keeps the steps recorded before the crash. Teardown does not attach: finalizer steps are cleanup. Each attach drops the item's earlier step entries, so the second attach and a --reruns 1 retry replace the story rather than doubling it
Steps ride out as repeated <property name="step"> entries behind the fixed package/covers/source prefix, which stays byte-identical. The project-releaser emitter already regroups them into the results JSON's steps array. test_junit_report.py runs real pytest with --junitxml against this conftest, in-process and under -n 2, and pins the passing, failing, setup-error, rerun and wide-scope-fixture cases on the parsed XML
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""Read-back for the Weave (Weights & Biases) logging tests against the real
|
|
Weave project.
|
|
|
|
The proxy ships OTEL spans to https://trace.wandb.ai/otel/v1/traces with the
|
|
``weave_otel`` callback, and the tests read the ingested calls back through
|
|
Weave's own query API (``POST /calls/stream_query``), which answers JSON Lines:
|
|
one JSON object per call, so the body is parsed line by line rather than as one
|
|
document.
|
|
|
|
The project is shared with other traffic, so the read never relies on the target
|
|
being among the newest N calls: the query is scoped server-side to the
|
|
``litellm_request`` op and to calls that started after the test's own request,
|
|
and pages with ``offset`` until the window is exhausted.
|
|
|
|
Weave's own ``summary.weave.status`` is a rollup that reads "success" even for a
|
|
span the exporter marked failed, so status comes from the OTEL span itself
|
|
(``attributes.otel_span.status.code``), and the shipped cost from
|
|
``attributes.otel_span.attributes.llm.response.cost`` - the StandardLogging
|
|
``response_cost``, which is what makes this a spend assertion rather than a
|
|
delivery ping.
|
|
|
|
Missing configuration is a hard failure, never a skip.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from itertools import count, takewhile
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
|
|
from e2e_http import URL, AuthHeaders, send
|
|
from e2e_metadata import step
|
|
|
|
_WEAVE_TRACE_API: Final = "https://trace.wandb.ai"
|
|
|
|
#: The op every litellm LLM call lands under. The proxy also exports a root
|
|
#: server span ("Received Proxy Server Request") and management spans; only the
|
|
#: LLM call carries the usage and cost this suite asserts on.
|
|
LITELLM_REQUEST_OP: Final = "litellm_request"
|
|
|
|
#: How long to keep re-reading after the first matching call before trusting the
|
|
#: exactly-one assertion. The OTEL batch exporter flushes on its own schedule, so
|
|
#: a duplicate export can surface well after the first one, and a duplicate IS
|
|
#: the bug being guarded against.
|
|
WEAVE_SETTLE_SECONDS: Final = 45.0
|
|
|
|
#: Rows per page. The query is already scoped to this run's time window, so this
|
|
#: only bounds one round trip, not what the read can see.
|
|
_PAGE_SIZE: Final = 500
|
|
|
|
|
|
class _WeaveSortBy(BaseModel):
|
|
field: str
|
|
direction: str
|
|
|
|
|
|
class _WeaveOpFilter(BaseModel):
|
|
op_names: list[str]
|
|
|
|
|
|
class _WeaveGetField(BaseModel):
|
|
get_field: str = Field(serialization_alias="$getField")
|
|
|
|
|
|
class _WeaveLiteral(BaseModel):
|
|
literal: float = Field(serialization_alias="$literal")
|
|
|
|
|
|
class _WeaveGreaterThan(BaseModel):
|
|
gt: tuple[_WeaveGetField, _WeaveLiteral] = Field(serialization_alias="$gt")
|
|
|
|
|
|
class _WeaveQuery(BaseModel):
|
|
expr: _WeaveGreaterThan = Field(serialization_alias="$expr")
|
|
|
|
|
|
class _WeaveQueryBody(BaseModel):
|
|
project_id: str
|
|
filter: _WeaveOpFilter
|
|
query: _WeaveQuery
|
|
limit: int = _PAGE_SIZE
|
|
offset: int = 0
|
|
sort_by: list[_WeaveSortBy] = [_WeaveSortBy(field="started_at", direction="asc")]
|
|
|
|
|
|
class _OtelStatus(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
code: str | None = None
|
|
message: str | None = None
|
|
|
|
|
|
class _OtelError(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
code: str | None = None
|
|
type: str | None = None
|
|
message: str | None = None
|
|
|
|
|
|
class _LlmResponse(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
cost: float | None = None
|
|
|
|
|
|
class _LlmAttributes(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
response: _LlmResponse | None = None
|
|
|
|
|
|
class _OtelSpanAttributes(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
llm: _LlmAttributes | None = None
|
|
error: _OtelError | None = None
|
|
|
|
|
|
class _OtelSpan(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
name: str | None = None
|
|
status: _OtelStatus | None = None
|
|
attributes: _OtelSpanAttributes | None = None
|
|
|
|
|
|
class _CallAttributes(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
otel_span: _OtelSpan | None = None
|
|
|
|
|
|
class _Usage(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
total_tokens: int | None = None
|
|
|
|
|
|
class _WeaveSummary(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
usage: dict[str, _Usage] = {}
|
|
|
|
|
|
class WeaveCall(BaseModel):
|
|
"""One ingested Weave call, reduced to what the scenarios assert on."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
id: str
|
|
op_name: str
|
|
started_at: str | None = None
|
|
inputs: dict[str, object] = {}
|
|
attributes: _CallAttributes | None = None
|
|
summary: _WeaveSummary | None = Field(default=None)
|
|
|
|
@property
|
|
def op(self) -> str:
|
|
"""The bare op name out of ``weave:///<entity>/<project>/op/<op>:<digest>``."""
|
|
return self.op_name.split("/op/")[-1].split(":")[0]
|
|
|
|
@property
|
|
def status_code(self) -> str | None:
|
|
"""The OTEL span status, not Weave's own rollup (which reads "success"
|
|
even for a span the exporter marked ERROR)."""
|
|
span = self.attributes.otel_span if self.attributes else None
|
|
return span.status.code if span and span.status else None
|
|
|
|
@property
|
|
def error(self) -> _OtelError | None:
|
|
span = self.attributes.otel_span if self.attributes else None
|
|
return span.attributes.error if span and span.attributes else None
|
|
|
|
@property
|
|
def response_cost(self) -> float | None:
|
|
span = self.attributes.otel_span if self.attributes else None
|
|
llm = span.attributes.llm if span and span.attributes else None
|
|
return llm.response.cost if llm and llm.response else None
|
|
|
|
@property
|
|
def total_tokens(self) -> int | None:
|
|
"""Weave keys usage by model, so the total is summed across whatever
|
|
models the call reported."""
|
|
if not self.summary or not self.summary.usage:
|
|
return None
|
|
totals = [usage.total_tokens for usage in self.summary.usage.values() if usage.total_tokens is not None]
|
|
return sum(totals) if totals else None
|
|
|
|
def mentions(self, needle: str) -> bool:
|
|
return needle in json.dumps(self.inputs, default=str)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WeaveReader:
|
|
project_id: str
|
|
api_key: str
|
|
|
|
@property
|
|
def _headers(self) -> AuthHeaders:
|
|
"""Weave authenticates with HTTP Basic as the fixed user ``api``."""
|
|
token = base64.b64encode(f"api:{self.api_key}".encode()).decode()
|
|
return AuthHeaders(authorization=f"Basic {token}")
|
|
|
|
def _query_body(self, *, since: float, offset: int, op: str) -> _WeaveQueryBody:
|
|
return _WeaveQueryBody(
|
|
project_id=self.project_id,
|
|
filter=_WeaveOpFilter(op_names=[f"weave:///{self.project_id}/op/{op}:*"]),
|
|
query=_WeaveQuery(
|
|
expr=_WeaveGreaterThan(gt=(_WeaveGetField(get_field="started_at"), _WeaveLiteral(literal=since)))
|
|
),
|
|
offset=offset,
|
|
)
|
|
|
|
def _page(self, *, since: float, offset: int, op: str) -> tuple[WeaveCall, ...]:
|
|
outcome = send(
|
|
URL(f"{_WEAVE_TRACE_API}/calls/stream_query"),
|
|
headers=self._headers,
|
|
json=self._query_body(since=since, offset=offset, op=op),
|
|
)
|
|
if not outcome.ok:
|
|
pytest.fail(
|
|
f"Weave calls query for project {self.project_id!r} failed "
|
|
f"({outcome.status_code}): {outcome.body[:300]}"
|
|
)
|
|
return tuple(WeaveCall.model_validate_json(line) for line in outcome.body.splitlines() if line.strip())
|
|
|
|
@step("query Weave calls for the marker")
|
|
def calls_matching(self, marker: str, *, since: float, op: str = LITELLM_REQUEST_OP) -> tuple[WeaveCall, ...]:
|
|
"""Every call under ``op`` started after ``since`` whose inputs carry
|
|
``marker``, paging until the window is exhausted.
|
|
|
|
More than one is the duplicate-delivery bug, so this never collapses to a
|
|
single call.
|
|
"""
|
|
pages = tuple(
|
|
takewhile(
|
|
bool,
|
|
(self._page(since=since, offset=offset, op=op) for offset in count(0, _PAGE_SIZE)),
|
|
)
|
|
)
|
|
return tuple(call for page in pages for call in page if call.mentions(marker))
|
|
|
|
@step("poll Weave for the marker's calls")
|
|
def poll_calls_matching(self, marker: str, *, since: float, op: str = LITELLM_REQUEST_OP) -> tuple[WeaveCall, ...]:
|
|
"""Poll until the call is readable, then keep re-reading for
|
|
WEAVE_SETTLE_SECONDS so a duplicate exported by a later batch flush
|
|
cannot hide from the exactly-one assertion. A duplicate ends the settle
|
|
early, because more waiting cannot clear it."""
|
|
deadline = time.monotonic() + POLL_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
calls = self.calls_matching(marker, since=since, op=op)
|
|
if calls:
|
|
return self._settled(marker, since=since, op=op, first=calls)
|
|
time.sleep(POLL_INTERVAL)
|
|
return ()
|
|
|
|
def _settled(self, marker: str, *, since: float, op: str, first: tuple[WeaveCall, ...]) -> tuple[WeaveCall, ...]:
|
|
"""A transiently empty re-read never downgrades what was already seen."""
|
|
settle_deadline = time.monotonic() + WEAVE_SETTLE_SECONDS
|
|
latest = first # rebind-ok: one settle window, re-read per poll interval
|
|
while time.monotonic() < settle_deadline and len(latest) <= 1:
|
|
time.sleep(POLL_INTERVAL)
|
|
latest = self.calls_matching(marker, since=since, op=op) or latest
|
|
return latest
|
|
|
|
|
|
def build_weave_reader() -> WeaveReader:
|
|
project_id = (os.environ.get("WEAVE_PROJECT_ID") or os.environ.get("WANDB_PROJECT_ID") or "").strip()
|
|
api_key = os.environ.get("WANDB_API_KEY", "").strip()
|
|
if not project_id or not api_key:
|
|
pytest.fail(
|
|
"Weave e2e requires WANDB_API_KEY and WEAVE_PROJECT_ID (or WANDB_PROJECT_ID, "
|
|
"format <entity>/<project>): the test reads the proxy's weave_otel delivery "
|
|
"back from the real Weave project; missing credentials is a hard failure, not a skip"
|
|
)
|
|
return WeaveReader(project_id=project_id, api_key=api_key)
|