mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
* test(e2e): cover presidio post_call, tool_permission, and weave logging cells Five registry cells in Logging & Guardrails had no covering test. Each one now has a live scenario read back from the real destination: - guardrail.presidio.post_call.masks: an output-scoped Presidio guardrail anonymizes the PII the model repeats back. The prompt also asks for the address's local part, which Presidio does not mask, so one response proves the model saw the raw address (no pre-call masking) while the address itself comes back as <EMAIL_ADDRESS> - guardrail.tool_permission.pre_call.blocks / .allows: an allow-list of one tool. A request declaring an unlisted tool is rejected 400 naming it; a request declaring the permitted tool is served and carries x-litellm-applied-guardrails, so the allow half cannot pass by the guardrail never running - logging.niche_integrations.success.logs_spend / .failure.logs_spend: a key-scoped weave_otel callback delivers to the real Weave project, read back through Weave's query API. Success asserts exactly one call whose llm.response.cost equals the x-litellm-response-cost header; failure asserts one ERROR-status call naming the provider exception and carrying no cost Logging & Guardrails coverage goes 24/59 to 29/59. No registry rows are added. * test(e2e): make the tool-permission allow case deterministic and scope the Weave read-back Review follow-ups on the coverage PR. - the allow scenario forced the outcome to depend on whether the model felt like calling an optional tool, and checked for the tool name as a substring of the whole body, which a prose mention would satisfy. It now sends tool_choice="required" and asserts the parsed response carries exactly one tool call, for the permitted tool - the Weave read-back queried the newest 200 calls of a shared project and filtered client-side, so busy traffic could push the target out of the window and read as a delivery failure. The query now scopes server-side to the litellm_request op and to calls started after the request, and pages through the window with offset - the reader builds its results as tuples instead of accumulating into lists Also unblocks the lint gate: `basedpyright tests/e2e` runs only on PRs that touch tests/e2e, and it has been failing on staging for three FakeItem arguments in test_junit_properties.py. The stand-in now goes through one typed adapter that says why, so the gate is green without touching junit_properties.py itself. * test(e2e): scope the presidio post_call guardrail to email and phone Running the suite three times in a row caught a real flake: Presidio's broader recognizers sometimes claim the email's local part as an NRP entity, so the answer came back as `<NRP>\n<EMAIL_ADDRESS>\n<PHONE_NUMBER>` and the assertion that the raw local part survives failed. That token is what tells output masking apart from input masking, so it has to survive. The post_call guardrail now registers pii_entities_config for EMAIL_ADDRESS and PHONE_NUMBER only, which is also the narrower thing the scenario means. Verified against the exact marker that failed, plus two others. * test(e2e): mark weave logging cells stage red * test(e2e): use per-test stage red skips for the weave logging cells
282 lines
9.9 KiB
Python
282 lines
9.9 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
|
|
|
|
_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())
|
|
|
|
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))
|
|
|
|
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)
|