mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
* fix(router): carry a 429 status on cooldown and no-deployment errors RouterRateLimitError and RouterRateLimitErrorBasic had no status_code, so the proxy answered an all-deployments-cooled-down request as a generic 500 class error and logged the bug report notice for every cooldown. Both now carry status_code 429, which the proxy maps to a rate_limit_error and which the retry policy already treats as retryable * fix(proxy): start Sentry at config load and never attach request bodies Sentry only came up inside set_callbacks on the first LLM call, so boot and background errors were never reported, and general_settings.alerting: [sentry] always raised Missing SENTRY_DSN because the alerting path read a dead litellm.utils pin instead of the live SDK. The proxy now initializes Sentry once while loading the config whenever sentry is a configured callback or alerting target, the alerting and failure handlers read the live instance, and max_request_body_size is pinned to never so prompts and completions stay out of Sentry events * fix(tracing): log Lens failures at ERROR and export row counters to Prometheus A tracing store that fails to start, or a Lens service that rejects a batch, was only logged at WARNING, so operators running at the default ERROR level never saw tracing silently go dark. Both now log at ERROR and name the lens store. The exporter's rows_written and rows_dropped tallies were never exported, so the two counts are now Prometheus counters litellm_lens_export_rows_written_total and litellm_lens_export_rows_dropped_total, registered lazily on the default registry like the other proxy-level counters and charted on the all-metrics Grafana dashboard * test(proxy): drop the litellm import the alerting tests no longer use * fix(proxy): start Sentry once a DSN is set, from any config source Boot-time init used to lock Sentry in even with no SENTRY_DSN, so a DSN loaded later from DB environment_variables was never picked up. initialize_sentry now skips when no DSN is set and re-inits when the DSN changes. alerting_handler calls it too, so alerting: [sentry] set through the DB works. Drops the litellm_settings.callbacks check since that config fails to load with sentry in it * fix(sentry): keep stack frame locals out of events unless PII is opted in Frame locals carried prompts (messages, request bodies, provider options) into every error event. include_local_variables now follows SENTRY_SEND_DEFAULT_PII * test(router): drop the test that only pinned the 429 class attribute * fix(proxy): start Sentry at boot when it is only an input callback * fix(proxy): keep Sentry init and alerting within the strict ruff and basedpyright ceilings --------- Co-authored-by: yuneng-jiang <yuneng@berri.ai>
358 lines
14 KiB
Python
358 lines
14 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
from itertools import chain
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
from prometheus_client import REGISTRY
|
|
|
|
from litellm.tracing.exporter import MAX_BUFFER_EVENTS, MAX_EVENT_BYTES, ExportFailure, LensExporter, encode_record
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failed", (False, True), ids=("success-event", "failure-event"))
|
|
@pytest.mark.parametrize(
|
|
("tags", "expected"),
|
|
(
|
|
pytest.param((7,), ("7",), id="numeric"),
|
|
pytest.param(("env:prod", 0, -7, 2.5, True, None), ("env:prod", "0", "-7", "2.5", "True", "None"), id="mixed"),
|
|
pytest.param(("env:prod", "agent:research"), ("env:prod", "agent:research"), id="strings"),
|
|
),
|
|
)
|
|
async def test_request_tags_are_normalized_without_dropping_the_record(
|
|
failed: bool, tags: tuple[object, ...], expected: tuple[str, ...]
|
|
) -> None:
|
|
requests: Final = asyncio.Queue[httpx.Request]()
|
|
|
|
def accept(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request)
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
exporter.start()
|
|
callback: Final = exporter.async_log_failure_event if failed else exporter.async_log_success_event
|
|
await callback(
|
|
{
|
|
"response_cost": 0.12,
|
|
"standard_logging_object": {
|
|
"id": "tagged-request",
|
|
"status": "failure" if failed else "success",
|
|
"response_cost": 0.12,
|
|
"request_tags": list(tags),
|
|
},
|
|
},
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
await exporter.aclose()
|
|
assert exporter.rows_written == 1
|
|
assert exporter.rows_dropped == 0
|
|
assert requests.qsize() == 1
|
|
request: Final = requests.get_nowait()
|
|
rows: Final = json.loads(request.content)
|
|
assert request.url.path == "/internal/spend"
|
|
assert len(rows) == 1
|
|
assert rows[0]["request_id"] == "tagged-request"
|
|
assert rows[0]["status"] == ("failure" if failed else "success")
|
|
assert rows[0]["spend"] == 0.12
|
|
assert rows[0]["request_tags"] == list(expected)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_export_ignores_unrelated_model_metadata_and_preserves_billing() -> None:
|
|
received: Final = asyncio.Future[httpx.Request]()
|
|
|
|
async def accept(request: httpx.Request) -> httpx.Response:
|
|
received.set_result(request)
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens.test/prefix/", transport=httpx.MockTransport(accept)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
exporter.start()
|
|
await exporter.async_log_success_event(
|
|
{
|
|
"response_cost": 0.12,
|
|
"standard_logging_object": {
|
|
"id": "response-test",
|
|
"status": "success",
|
|
"call_type": "acompletion",
|
|
"model": "test-model",
|
|
"response_cost": 0.12,
|
|
"model_map_information": {"model_map_value": {"extra_pricing_metadata": None}},
|
|
"metadata": {"user_api_key_hash": "hash-test", "user_api_key_team_id": "team-test"},
|
|
"messages": [{"role": "user", "content": "Check a refund"}],
|
|
"response": {"choices": [{"message": {"content": "Refund failed"}}]},
|
|
},
|
|
},
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
request: Final = await asyncio.wait_for(received, timeout=1)
|
|
await exporter.aclose()
|
|
rows: Final = json.loads(request.content)
|
|
assert request.url.path == "/prefix/internal/spend"
|
|
assert len(rows) == 1
|
|
assert rows[0]["response_id"] == "response-test"
|
|
assert rows[0]["spend"] == 0.12
|
|
assert rows[0]["api_key"] == "hash-test"
|
|
assert rows[0]["team_id"] == "team-test"
|
|
assert json.loads(rows[0]["messages"]) == [{"role": "user", "content": "Check a refund"}]
|
|
assert exporter.rows_written == 1
|
|
assert exporter.rows_dropped == 0
|
|
assert exporter.buffered_bytes == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_records_count_toward_the_queue_limit() -> None:
|
|
started: Final = asyncio.Event()
|
|
release: Final = asyncio.Event()
|
|
|
|
async def blocked(request: httpx.Request) -> httpx.Response:
|
|
started.set()
|
|
await release.wait()
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens.test", transport=httpx.MockTransport(blocked)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
for _ in range(MAX_BUFFER_EVENTS):
|
|
assert exporter.enqueue(b"{}")
|
|
exporter.start()
|
|
await asyncio.wait_for(started.wait(), timeout=1)
|
|
assert not exporter.enqueue(b"{}")
|
|
assert exporter.buffered_events == MAX_BUFFER_EVENTS
|
|
assert exporter.rows_dropped == 1
|
|
release.set()
|
|
await exporter.aclose()
|
|
assert exporter.rows_written == MAX_BUFFER_EVENTS
|
|
assert exporter.buffered_events == 0
|
|
assert exporter.buffered_bytes == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["a" * MAX_EVENT_BYTES, "界" * (MAX_EVENT_BYTES // 2), "\x01" * (MAX_EVENT_BYTES // 4)],
|
|
ids=("oversized-ascii", "oversized-unicode", "escaped-json-expansion"),
|
|
)
|
|
def test_oversized_event_is_rejected_before_queueing(value: str) -> None:
|
|
assert encode_record({"messages": value}) is ExportFailure.TOO_LARGE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", (429, 502, 503, 504, 0))
|
|
async def test_transient_failures_retry_the_same_batch_and_recover(status: int) -> None:
|
|
requests: Final = asyncio.Queue[bytes]()
|
|
waits: Final = asyncio.Queue[float]()
|
|
|
|
async def retry_delay(seconds: float) -> None:
|
|
waits.put_nowait(seconds)
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request.content)
|
|
if requests.qsize() == 3:
|
|
return httpx.Response(204)
|
|
if status == 0:
|
|
raise httpx.ConnectError("private storage host", request=request)
|
|
return httpx.Response(status)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(respond)) as client:
|
|
exporter: Final = LensExporter(client, sleep=retry_delay)
|
|
assert exporter.enqueue(b'{"id":1}')
|
|
exporter.start()
|
|
await exporter.aclose()
|
|
assert tuple(requests.get_nowait() for _ in range(3)) == (b'[{"id":1}]',) * 3
|
|
assert tuple(waits.get_nowait() for _ in range(2)) == (1.0, 2.0)
|
|
assert (exporter.rows_written, exporter.rows_dropped, exporter.buffered_bytes, exporter.buffered_events) == (
|
|
1,
|
|
0,
|
|
0,
|
|
0,
|
|
)
|
|
assert exporter.last_error == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"status,attempts,reason", ((401, 1, "HTTP 401"), (500, 1, "HTTP 500"), (503, 3, "retry limit reached"))
|
|
)
|
|
async def test_failed_exports_are_counted_and_release_all_buffer_capacity(
|
|
status: int, attempts: int, reason: str
|
|
) -> None:
|
|
requests: Final = asyncio.Queue[bytes]()
|
|
|
|
async def no_wait(_: float) -> None:
|
|
return None
|
|
|
|
def reject(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request.content)
|
|
return httpx.Response(status, text="private credentials")
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(reject)) as client:
|
|
exporter: Final = LensExporter(client, sleep=no_wait)
|
|
assert exporter.enqueue(b"{}")
|
|
exporter.start()
|
|
exporter.start()
|
|
await exporter.aclose()
|
|
assert requests.qsize() == attempts
|
|
assert (exporter.rows_written, exporter.rows_dropped, exporter.buffered_bytes, exporter.buffered_events) == (
|
|
0,
|
|
1,
|
|
0,
|
|
0,
|
|
)
|
|
assert exporter.last_error == reason
|
|
assert not exporter.enqueue(b"{}")
|
|
assert exporter.rows_dropped == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_inflight_export_drops_the_batch_and_pending_records() -> None:
|
|
started: Final = asyncio.Event()
|
|
|
|
async def block(_: httpx.Request) -> httpx.Response:
|
|
started.set()
|
|
await asyncio.Future[None]()
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(block)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
assert exporter.enqueue(b"{}")
|
|
exporter.start()
|
|
await started.wait()
|
|
assert exporter.enqueue(b"{}")
|
|
assert exporter.task is not None
|
|
exporter.task.cancel()
|
|
await exporter.aclose()
|
|
assert (exporter.rows_written, exporter.rows_dropped, exporter.buffered_bytes, exporter.buffered_events) == (
|
|
0,
|
|
2,
|
|
0,
|
|
0,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byte_budget_rejects_large_queue_and_shutdown_without_start_discards_it() -> None:
|
|
from litellm.tracing.exporter import MAX_BUFFER_BYTES
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens") as client:
|
|
exporter: Final = LensExporter(client)
|
|
assert not exporter.enqueue(b"x" * (MAX_EVENT_BYTES + 1))
|
|
for _ in range(MAX_BUFFER_BYTES // MAX_EVENT_BYTES):
|
|
assert exporter.enqueue(b"x" * MAX_EVENT_BYTES)
|
|
assert not exporter.enqueue(b"x")
|
|
assert exporter.buffered_bytes == MAX_BUFFER_BYTES
|
|
await exporter.aclose()
|
|
assert exporter.rows_dropped == MAX_BUFFER_BYTES // MAX_EVENT_BYTES + 2
|
|
assert (exporter.buffered_bytes, exporter.buffered_events) == (0, 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batches_stay_bounded_without_losing_or_reordering_records() -> None:
|
|
from litellm.tracing.exporter import MAX_BATCH_BYTES
|
|
|
|
bodies: Final = asyncio.Queue[bytes]()
|
|
|
|
def accept(request: httpx.Request) -> httpx.Response:
|
|
bodies.put_nowait(request.content)
|
|
return httpx.Response(204)
|
|
|
|
records: Final = tuple(encode_record({"id": index, "text": "x" * (MAX_EVENT_BYTES // 2)}) for index in range(10))
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
for record in records:
|
|
assert isinstance(record, bytes)
|
|
assert exporter.enqueue(record)
|
|
exporter.start()
|
|
await exporter.aclose()
|
|
sent: Final = tuple(bodies.get_nowait() for _ in range(bodies.qsize()))
|
|
assert len(sent) == 2
|
|
assert all(len(body) <= MAX_BATCH_BYTES for body in sent)
|
|
assert [row["id"] for row in chain.from_iterable(json.loads(body) for body in sent)] == list(range(10))
|
|
assert exporter.rows_written == 10
|
|
assert exporter.rows_dropped == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
(
|
|
None,
|
|
{"id": []},
|
|
{"id": "x", "messages": [{"content": "x" * MAX_EVENT_BYTES}]},
|
|
{"id": "x", "messages": [{"content": "\x01" * (MAX_EVENT_BYTES // 4)}]},
|
|
{"id": "x", "response_cost": float("nan")},
|
|
),
|
|
ids=("missing", "invalid-id", "oversized-input", "escaped-row-expansion", "invalid-row-number"),
|
|
)
|
|
async def test_invalid_callback_data_never_interrupts_model_requests(payload: object) -> None:
|
|
async with httpx.AsyncClient(base_url="http://lens") as client:
|
|
exporter: Final = LensExporter(client)
|
|
await exporter.async_log_failure_event({"standard_logging_object": payload}, None, None, None)
|
|
await exporter.aclose()
|
|
assert exporter.rows_written == 0
|
|
assert exporter.buffered_bytes == 0
|
|
assert exporter.rows_dropped == (0 if payload is None else 1)
|
|
|
|
|
|
def test_serialization_rejects_recursive_payloads() -> None:
|
|
cyclic: Final[dict[str, object]] = {} # mutable-ok: deliberately constructs a cyclic callback payload
|
|
cyclic["self"] = cyclic
|
|
assert encode_record(cyclic) is ExportFailure.TOO_LARGE
|
|
|
|
|
|
@pytest.mark.parametrize("value", (float("nan"), object(), "\ud800"), ids=("nan", "unsupported", "surrogate"))
|
|
def test_serialization_returns_a_failure_for_invalid_payloads(value: object) -> None:
|
|
assert encode_record({"value": value}) is ExportFailure.INVALID
|
|
|
|
|
|
def _counter_value(name: str) -> float:
|
|
return REGISTRY.get_sample_value(name) or 0.0
|
|
|
|
|
|
async def _drained(exporter: LensExporter) -> None:
|
|
while exporter.buffered_events:
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_written_and_dropped_rows_are_exported_as_prometheus_counters() -> None:
|
|
statuses: Final = iter((204, 500))
|
|
|
|
def answer(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(next(statuses))
|
|
|
|
written_before: Final = _counter_value("litellm_lens_export_rows_written_total")
|
|
dropped_before: Final = _counter_value("litellm_lens_export_rows_dropped_total")
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(answer)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
exporter.start()
|
|
for request_id in ("accepted", "rejected"):
|
|
await exporter.async_log_success_event(
|
|
{"standard_logging_object": {"id": request_id, "response_cost": 0.1}}, None, None, None
|
|
)
|
|
await _drained(exporter)
|
|
await exporter.aclose()
|
|
assert (exporter.rows_written, exporter.rows_dropped) == (1, 1)
|
|
assert _counter_value("litellm_lens_export_rows_written_total") - written_before == exporter.rows_written
|
|
assert _counter_value("litellm_lens_export_rows_dropped_total") - dropped_before == exporter.rows_dropped
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_rejecting_store_is_logged_as_an_error_naming_lens(caplog: pytest.LogCaptureFixture) -> None:
|
|
def reject(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(500)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(reject)) as client:
|
|
exporter: Final = LensExporter(client)
|
|
assert exporter.enqueue(b"{}")
|
|
exporter.start()
|
|
await exporter.aclose()
|
|
failures: Final = [record.getMessage() for record in caplog.records if record.levelno == logging.ERROR]
|
|
assert len(failures) == 1
|
|
assert "lens" in failures[0] and "HTTP 500" in failures[0]
|