mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
Adds live coverage for three P0 logging-registry cells against a real proxy and the real sinks: logging.s3.success.writes_object, logging.s3.failure.writes_object and logging.datadog.failure.exports_metric. The failure cases drive a genuine upstream rejection; each test registers a deployment whose provider api_key is invalid, so OpenAI itself returns the 401 and litellm's failure path is what has to deliver. Delivery is then read back out of the sink, never inferred from the proxy's own response: the s3 tests fetch the object with the AWS SDK and assert the stored payload's status, model, cost and prompt, while the datadog test reuses the existing logs-search reader and asserts the event's failure status alongside the provider's error class, code and name. Every object a test writes is deleted on teardown. Both failure tests correlate on the x-litellm-call-id of the attempt they accepted rather than on the prompt. A rejection at the gateway is logged as a failure too, carrying the same prompt, so a virtual key that briefly 401s before the auth cache catches up would otherwise contribute a second record and turn the exactly-one assertion red on correct behavior. A logging integration is a process-wide callback rather than a per-request option, so callback_config.py lets a test declare the destination it needs: it reads the registered callbacks back from /get/config/callbacks, registers the missing one through /config/update, and unregisters exactly what it registered afterwards. A proxy that already ships the integration is left untouched. Every write is a read-modify-write of the live callback list, so enabling or disabling a destination cannot clobber a registration made concurrently by another test on the same proxy. A read-modify-write is still not atomic and cannot be made so here, because the config API offers only a whole-list write and a server-side read-remove-write, with no per-entry update to compare-and-set against. Each write therefore re-reads the list and fails, naming the entries, if anything registered beforehand that belongs to someone else has gone, which turns a silent change to a shared proxy's logging configuration into a diagnosable failure. Entries that appear only after a write are a later registration rather than damage and are left alone.
197 lines
7.2 KiB
Python
197 lines
7.2 KiB
Python
"""Read-back for the S3 logging tests against the real bucket the proxy writes to.
|
|
|
|
Delivery is judged on what actually landed in S3: the proxy uploads with its own
|
|
AWS credentials exactly as in production (no local minio, no endpoint override),
|
|
and the tests fetch the object back with the official AWS SDK. Which bucket the
|
|
proxy is configured to write to is not discoverable over any proxy route, so the
|
|
test process is told through E2E_S3_LOG_BUCKET (plus E2E_S3_LOG_REGION,
|
|
E2E_S3_LOG_PATH when the deployment sets an s3_path prefix, and
|
|
E2E_S3_LOG_PROFILE to read back through a named AWS profile). A missing bucket is
|
|
a hard failure, never an empty result.
|
|
|
|
An object's key ends in the id litellm assigned the call - the completion id for
|
|
a successful call, the litellm call id (the x-litellm-call-id response header)
|
|
for a failed one - so a test locates its own object exactly, without scanning
|
|
anyone else's, and deletes just that key on teardown.
|
|
|
|
boto3 is the AWS SDK's own client and is deliberately not routed through
|
|
``e2e_http``: hand-signing SigV4 to reach S3 would be a worse test than using the
|
|
vendor client. Its s3 client is untyped, so it is confined to this module behind
|
|
the ``_S3Client`` protocol, its responses are narrowed here, and every payload is
|
|
validated into a pydantic model before a test sees it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from typing import Protocol, cast
|
|
|
|
import boto3
|
|
import pytest
|
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
|
|
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
|
|
|
|
S3_LOG_BUCKET = os.environ.get("E2E_S3_LOG_BUCKET", "").strip()
|
|
S3_LOG_REGION = os.environ.get("E2E_S3_LOG_REGION", "us-east-1").strip()
|
|
#: The deployment's litellm_settings.s3_callback_params.s3_path, if it sets one.
|
|
S3_LOG_PATH = os.environ.get("E2E_S3_LOG_PATH", "").strip()
|
|
#: Named AWS profile for the read-back; empty uses boto3's default credential chain.
|
|
S3_LOG_PROFILE = os.environ.get("E2E_S3_LOG_PROFILE", "").strip()
|
|
|
|
|
|
class _S3Body(Protocol):
|
|
def read(self) -> bytes: ...
|
|
|
|
|
|
class _S3Client(Protocol):
|
|
"""The three S3 calls this module makes."""
|
|
|
|
def list_objects_v2(self, **kwargs: str) -> object: ...
|
|
|
|
def get_object(self, *, Bucket: str, Key: str) -> object: ...
|
|
|
|
def delete_object(self, *, Bucket: str, Key: str) -> object: ...
|
|
|
|
|
|
class S3LogError(BaseModel):
|
|
"""The error_information block of a failed call's payload."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
error_class: str = ""
|
|
error_code: str = ""
|
|
llm_provider: str = ""
|
|
|
|
|
|
class S3LogMessage(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
role: str
|
|
content: str
|
|
|
|
|
|
class S3LogRecord(BaseModel):
|
|
"""The StandardLoggingPayload as the proxy wrote it into the bucket. Only the
|
|
fields the delivery tests pin are modelled."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
id: str
|
|
status: str
|
|
call_type: str
|
|
model_group: str
|
|
response_cost: float
|
|
total_tokens: int
|
|
messages: tuple[S3LogMessage, ...] = ()
|
|
error_information: S3LogError = S3LogError()
|
|
error_str: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class S3LogObject:
|
|
key: str
|
|
record: S3LogRecord
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ListPage:
|
|
keys: tuple[str, ...]
|
|
next_token: str | None
|
|
|
|
|
|
def _s3_client() -> _S3Client:
|
|
session = boto3.Session(profile_name=S3_LOG_PROFILE) if S3_LOG_PROFILE else boto3.Session()
|
|
client = session.client("s3", region_name=S3_LOG_REGION) # pyright: ignore[reportUnknownMemberType] # boto3 ships no types for the per-service clients; _S3Client pins the calls made here
|
|
return cast(_S3Client, client)
|
|
|
|
|
|
def _response(raw: object, call: str) -> dict[str, object]:
|
|
if not isinstance(raw, dict):
|
|
pytest.fail(f"S3 {call} returned {type(raw).__name__}, not a response mapping")
|
|
return cast(dict[str, object], raw)
|
|
|
|
|
|
def _list_page(raw: object) -> _ListPage:
|
|
listing = _response(raw, "list_objects_v2")
|
|
contents = listing.get("Contents", [])
|
|
if not isinstance(contents, list):
|
|
pytest.fail(f"S3 list_objects_v2 Contents was {type(contents).__name__}, not a list")
|
|
keys = tuple(
|
|
key
|
|
for entry in cast(list[object], contents)
|
|
if isinstance(entry, dict) and isinstance(key := cast(dict[str, object], entry).get("Key"), str)
|
|
)
|
|
token = listing.get("NextContinuationToken")
|
|
truncated = listing.get("IsTruncated") is True
|
|
return _ListPage(keys=keys, next_token=token if truncated and isinstance(token, str) else None)
|
|
|
|
|
|
def _body(raw: object) -> bytes:
|
|
stream = _response(raw, "get_object").get("Body")
|
|
if not hasattr(stream, "read"):
|
|
pytest.fail(f"S3 get_object returned a Body of {type(stream).__name__}, which is not readable")
|
|
return cast(_S3Body, stream).read()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class S3LogsReader:
|
|
bucket: str
|
|
path: str
|
|
|
|
def _keys(self, client: _S3Client) -> Iterator[str]:
|
|
page = _list_page(client.list_objects_v2(Bucket=self.bucket, Prefix=self.path))
|
|
while True:
|
|
yield from page.keys
|
|
if page.next_token is None:
|
|
return
|
|
page = _list_page(
|
|
client.list_objects_v2(Bucket=self.bucket, Prefix=self.path, ContinuationToken=page.next_token)
|
|
)
|
|
|
|
def _record(self, client: _S3Client, key: str) -> S3LogRecord:
|
|
body = _body(client.get_object(Bucket=self.bucket, Key=key))
|
|
try:
|
|
return S3LogRecord.model_validate_json(body)
|
|
except ValidationError as exc:
|
|
pytest.fail(f"the object at {key} is not a StandardLoggingPayload: {exc}")
|
|
|
|
def objects_for_call(self, call_id: str) -> tuple[S3LogObject, ...]:
|
|
"""Every object the proxy wrote for the call litellm identified as
|
|
``call_id``. More than one is a duplicate-delivery bug, so this never
|
|
collapses to a single object."""
|
|
client = _s3_client()
|
|
suffix = f"_{call_id}.json"
|
|
return tuple(
|
|
S3LogObject(key=key, record=self._record(client, key))
|
|
for key in self._keys(client)
|
|
if key.endswith(suffix)
|
|
)
|
|
|
|
def poll_objects_for_call(self, call_id: str) -> tuple[S3LogObject, ...]:
|
|
"""Poll until the call's object is readable - the integration batches
|
|
uploads behind a flush interval - and return whatever is there at the
|
|
deadline, so the caller's assertion, not a timeout, reports the gap."""
|
|
deadline = time.monotonic() + POLL_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
found = self.objects_for_call(call_id)
|
|
if found:
|
|
return found
|
|
time.sleep(POLL_INTERVAL)
|
|
return self.objects_for_call(call_id)
|
|
|
|
def delete(self, key: str) -> None:
|
|
_ = _s3_client().delete_object(Bucket=self.bucket, Key=key)
|
|
|
|
|
|
def build_s3_logs_reader() -> S3LogsReader:
|
|
if not S3_LOG_BUCKET:
|
|
pytest.fail(
|
|
"E2E_S3_LOG_BUCKET must name the bucket the proxy's s3_callback_params write to: "
|
|
"the s3 tests read delivery back out of the real bucket, and no proxy route reports "
|
|
"which one is configured; missing it is a hard failure, not a skip"
|
|
)
|
|
return S3LogsReader(bucket=S3_LOG_BUCKET, path=S3_LOG_PATH)
|