litellm/tests/e2e/logging/callback_config.py
Yassin Kortam 2090047bd3 test(e2e): cover s3 object delivery and datadog failure logging
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.
2026-07-27 17:37:57 -07:00

224 lines
9.3 KiB
Python

"""Turn a logging integration on for the duration of a test, then put the proxy
back the way it was found.
A litellm logging integration is a process-wide callback, not a per-request
option, so a delivery test can only assert against a proxy that has the
integration registered for the event it cares about. Rather than depend on how
the proxy under test happened to be launched, a test declares what it needs:
``callback_enabled`` reads the registered callbacks back from
/get/config/callbacks, registers the missing one through /config/update, waits
until the proxy reports it, and unregisters exactly what it registered on the
way out. A proxy that already ships the integration is left untouched, so this
is a no-op wherever the destination is already wired into the deployment.
Success and failure are separate registrations in litellm and are removed by
separate routes: /config/update unions into success_callback (callbacks are
additive there, so a shorter list cannot remove one) and /config/callback/delete
is the only way back out, while failure_callback is written wholesale. Every
write here is therefore a read-modify-write of the list as it stands at that
moment, adding or dropping just this caller's entry. Restoring a snapshot taken
at setup would unregister whatever a concurrently running test had registered in
the meantime, which on a shared proxy is a real way to break someone else's run.
A read-modify-write is still not atomic, and nothing available here can make it
so: litellm exposes a whole-list write and a server-side read-remove-write, with
no per-entry update and no conditional write, so there is no compare-and-set to
build ownership on, and a reference count would be one more read-then-write over
the same shared state. What is available is detection. Every write re-reads the
list afterwards and fails, naming the entries, if anything that was registered
before it and belongs to somebody 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 somebody else's later registration, not damage,
and are left alone.
All of this assumes one sequential pytest session per proxy, which is what the
rest of the harness assumes anyway; the session-scoped proxy fixture and the
destructive spend-log truncate in the root conftest are both single-session by
construction. Run against a shared proxy under xdist the detection still fires,
but it reports the collision rather than preventing it.
/get/config/callbacks also returns each integration's resolved credentials. The
model here keeps only the name and the event type, so no secret is ever parsed
into a test.
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from typing import Literal, assert_never
import pytest
from pydantic import BaseModel, ConfigDict
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
from e2e_http import NoBody, unwrap
from logging_client import LoggingClient
type CallbackEvent = Literal["success", "failure"]
def _matching_types(event: CallbackEvent) -> frozenset[str]:
"""The /get/config/callbacks ``type`` values that register for ``event``:
litellm_settings.callbacks registers for both and is reported as
success_and_failure."""
match event:
case "success":
return frozenset({"success", "success_and_failure"})
case "failure":
return frozenset({"failure", "success_and_failure"})
case _:
assert_never(event)
class _ConfiguredCallback(BaseModel):
"""One /get/config/callbacks entry. The route also returns the
integration's resolved credentials under ``variables``; leaving them
unmodelled keeps them out of the test process."""
model_config = ConfigDict(extra="ignore")
name: str
type: str
class _ConfiguredCallbacks(BaseModel):
model_config = ConfigDict(extra="ignore")
callbacks: list[_ConfiguredCallback] = []
class _CallbackLists(BaseModel):
"""The litellm_settings slice /config/update needs. Only the list for the
event being changed is sent; the other stays None and is dropped."""
success_callback: list[str] | None = None
failure_callback: list[str] | None = None
class _ConfigUpdateBody(BaseModel):
litellm_settings: _CallbackLists
class _ConfigUpdateAck(BaseModel):
model_config = ConfigDict(extra="ignore")
message: str
class _CallbackDeleteBody(BaseModel):
callback_name: str
class _CallbackDeleteAck(BaseModel):
model_config = ConfigDict(extra="ignore")
removed_callback: str
def registered_callbacks(client: LoggingClient, event: CallbackEvent) -> tuple[str, ...]:
"""The integrations the proxy reports as registered for ``event``."""
reported = unwrap(
client.proxy.transport.get(
"/get/config/callbacks",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=_ConfiguredCallbacks,
)
)
wanted = _matching_types(event)
return tuple(callback.name for callback in reported.callbacks if callback.type in wanted)
def _write_callbacks(client: LoggingClient, event: CallbackEvent, names: tuple[str, ...]) -> None:
settings = (
_CallbackLists(success_callback=list(names))
if event == "success"
else _CallbackLists(failure_callback=list(names))
)
ack = unwrap(
client.proxy.transport.post(
"/config/update",
headers=client.proxy.transport.master,
json=_ConfigUpdateBody(litellm_settings=settings),
response_type=_ConfigUpdateAck,
)
)
assert "success" in ack.message.lower(), f"POST /config/update must acknowledge the write; got {ack.message!r}"
#: Why a vanished entry is worth stopping for, appended to both loss reports.
_LOST_ENTRIES = (
"the callback list has no atomic per-entry update, so a registration made between this "
"read-modify-write's read and its write is overwritten rather than merged; those entries are "
"gone from the proxy's configuration and anything relying on them is now logged differently"
)
def _register(client: LoggingClient, name: str, event: CallbackEvent) -> None:
before = registered_callbacks(client, event)
_write_callbacks(client, event, before + (name,))
deadline = time.monotonic() + POLL_TIMEOUT
while True:
settled = frozenset(registered_callbacks(client, event))
if name in settled:
lost = frozenset(before) - settled
if lost:
pytest.fail(
f"registering {name!r} for {event} calls dropped {sorted(lost)}: {_LOST_ENTRIES}"
)
return
if time.monotonic() >= deadline:
pytest.fail(f"the proxy never reported the {name!r} callback registered for {event} calls")
time.sleep(POLL_INTERVAL)
def _unregister(client: LoggingClient, name: str, event: CallbackEvent) -> frozenset[str]:
"""Drop only this test's own entry, and report anything else that vanished.
The list is re-read here and the name filtered out of what is registered
*now*, never replaced with the snapshot taken at setup: the proxy is shared,
so writing back a stale whole list would silently unregister a callback
something else added in between. The re-read afterwards catches the case that
remains, where something registered inside the window between this read and
this write and the write overwrote it. Returns those lost names rather than
failing, so the caller can finish unregistering every event before it
reports; leaving half the events registered would be a worse outcome than
the loss being reported one moment later."""
before = registered_callbacks(client, event)
if event == "failure":
_write_callbacks(client, event, tuple(n for n in before if n != name))
else:
_ = client.proxy.transport.post(
"/config/callback/delete",
headers=client.proxy.transport.master,
json=_CallbackDeleteBody(callback_name=name),
response_type=_CallbackDeleteAck,
)
return (frozenset(before) - {name}) - frozenset(registered_callbacks(client, event))
def callback_enabled(client: LoggingClient, name: str, *, events: tuple[CallbackEvent, ...]) -> Iterator[None]:
"""Guarantee ``name`` is registered for every event in ``events`` while the
generator is suspended, and no longer registered for the ones this call
added once it resumes. Every write is a read-modify-write of the live list
followed by a re-read, so concurrent registrations by other tests survive,
and one lost to the gap between the read and the write is reported instead
of disappearing. Registration happens inside the try, so a failure part way
through a multi-event registration still unregisters what it managed to add.
Drive it from a fixture with ``yield from``."""
added: tuple[CallbackEvent, ...] = tuple(
event for event in events if name not in registered_callbacks(client, event)
)
try:
for event in added:
_register(client, name, event)
yield
finally:
lost = tuple(
f"{event}/{missing}"
for event in added
for missing in sorted(_unregister(client, name, event))
)
if lost:
pytest.fail(f"unregistering {name!r} dropped {list(lost)}: {_LOST_ENTRIES}")