Files
litellm/tests/test_litellm/proxy/utils/proxy_logging/test_alerting.py
T
yuneng-jiang b175990b4a test(proxy/utils): pin ProxyLogging behavior (#29485)
* test(proxy/utils): pin ProxyLogging behavior

Add behavior-pinning tests for the ProxyLogging cluster in
litellm/proxy/utils.py under tests/test_litellm/proxy/utils/proxy_logging/.
Covers InternalUsageCache, _CallbackCapabilities, top-of-file helpers
(print_verbose, _get_email_logger_class, _accepts_litellm_call_info,
_enrich_http_exception_with_guardrail_context), the full ProxyLogging
class (lifecycle, MCP-LLM bridging, capability probes, guardrail
pipeline, pre/during/post/streaming hooks, alerting), plus the
bottom-of-region helpers (on_backoff, jsonify_object, _lookup_deprecated_key).

Each pinned symbol has happy-path and error-path coverage; happy paths
use direct dict-equality with three or more keys (or HiddenParams /
Pydantic model_validate where the surface is a Pydantic shape). The
subdirectory carries a local _pin_check.py and _coverage_check.py that
enforce the gate without surfacing numeric thresholds in CI logs.

Wires tests/test_litellm/proxy/utils into the existing test-path block
in .github/workflows/test-unit-proxy-endpoints.yml.

* test(proxy/utils): drop unused mock_httpx_client fixture

Declared in conftest.py but never referenced by any test. Removing
the dead fixture per Greptile P2 feedback.

* test(proxy/utils): drop local-only gate scripts from PR

_pin_check.py and _coverage_check.py are local stopping signals (not
wired into CI, consume a gitignored .pin_list.txt). They served their
purpose telling the engineer when to stop writing tests; the pytest
suite is the artifact that belongs in the repo.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 17:45:39 -07:00

263 lines
9.9 KiB
Python

"""Pin alerting helpers on ``ProxyLogging``.
Covers ``failed_tracking_alert``, ``budget_alerts``, ``alerting_handler``,
``failure_handler``.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
import litellm
from litellm.proxy._types import AlertType, CallInfo
# ---------------------------------------------------------------------------
# failed_tracking_alert
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_failed_tracking_alert_no_op_when_alerting_none(proxy_logging):
proxy_logging.alerting = None
proxy_logging.slack_alerting_instance = MagicMock(failed_tracking_alert=AsyncMock())
await proxy_logging.failed_tracking_alert(error_message="x", failing_model="m")
proxy_logging.slack_alerting_instance.failed_tracking_alert.assert_not_called()
@pytest.mark.asyncio
async def test_failed_tracking_alert_forwards_to_slack(proxy_logging):
proxy_logging.alerting = ["slack"]
captured: Dict[str, Any] = {}
async def fake_alert(**kwargs):
captured.update(kwargs)
proxy_logging.slack_alerting_instance = MagicMock(failed_tracking_alert=fake_alert)
await proxy_logging.failed_tracking_alert(error_message="db down", failing_model="gpt-4")
snapshot = {
"error_message": captured["error_message"],
"failing_model": captured["failing_model"],
"captured_keys": sorted(captured.keys()),
}
assert snapshot == {
"error_message": "db down",
"failing_model": "gpt-4",
"captured_keys": ["error_message", "failing_model"],
}
@pytest.mark.asyncio
async def test_failed_tracking_alert_slack_error_raises(proxy_logging):
proxy_logging.alerting = ["slack"]
proxy_logging.slack_alerting_instance = MagicMock(
failed_tracking_alert=AsyncMock(side_effect=RuntimeError("slack down"))
)
with pytest.raises(RuntimeError):
await proxy_logging.failed_tracking_alert(error_message="x", failing_model="m")
# ---------------------------------------------------------------------------
# budget_alerts
# ---------------------------------------------------------------------------
def _user_info(alert_emails=None):
return CallInfo(
spend=0.0,
max_budget=1.0,
token="tok",
user_id="u1",
team_id="t1",
team_alias=None,
user_email=None,
key_alias=None,
projected_exceeded_date=None,
projected_spend=None,
event_group="user",
event="threshold_crossed",
alert_emails=alert_emails,
)
@pytest.mark.asyncio
async def test_budget_alerts_no_op_when_alerting_off_and_no_emails(proxy_logging):
proxy_logging.alerting = None
proxy_logging.slack_alerting_instance = MagicMock(budget_alerts=AsyncMock())
proxy_logging.email_logging_instance = MagicMock(budget_alerts=AsyncMock())
await proxy_logging.budget_alerts(type="user_budget", user_info=_user_info())
proxy_logging.slack_alerting_instance.budget_alerts.assert_not_called()
proxy_logging.email_logging_instance.budget_alerts.assert_not_called()
@pytest.mark.asyncio
async def test_budget_alerts_slack_when_slack_alerting(proxy_logging):
proxy_logging.alerting = ["slack"]
captured: Dict[str, Any] = {}
async def fake_alert(**kwargs):
captured.update(kwargs)
proxy_logging.slack_alerting_instance = MagicMock(budget_alerts=fake_alert)
proxy_logging.email_logging_instance = None
user_info = _user_info()
await proxy_logging.budget_alerts(type="user_budget", user_info=user_info)
snapshot = {
"type": captured["type"],
"user_info_is_callinfo": isinstance(captured["user_info"], CallInfo),
"user_id": captured["user_info"].user_id,
}
assert snapshot == {"type": "user_budget", "user_info_is_callinfo": True, "user_id": "u1"}
@pytest.mark.asyncio
async def test_budget_alerts_soft_budget_with_alert_emails_bypasses_global(proxy_logging):
proxy_logging.alerting = None
proxy_logging.slack_alerting_instance = MagicMock(budget_alerts=AsyncMock())
proxy_logging.email_logging_instance = MagicMock(budget_alerts=AsyncMock())
info = _user_info(alert_emails=["a@b.c"])
await proxy_logging.budget_alerts(type="soft_budget", user_info=info)
proxy_logging.email_logging_instance.budget_alerts.assert_called_once()
proxy_logging.slack_alerting_instance.budget_alerts.assert_not_called()
@pytest.mark.asyncio
async def test_budget_alerts_slack_failure_raises(proxy_logging):
proxy_logging.alerting = ["slack"]
proxy_logging.slack_alerting_instance = MagicMock(
budget_alerts=AsyncMock(side_effect=ConnectionError("slack"))
)
proxy_logging.email_logging_instance = None
with pytest.raises(ConnectionError):
await proxy_logging.budget_alerts(type="user_budget", user_info=_user_info())
# ---------------------------------------------------------------------------
# alerting_handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_alerting_handler_no_op_when_alerting_is_none(proxy_logging):
proxy_logging.alerting = None
proxy_logging.slack_alerting_instance = MagicMock(send_alert=AsyncMock())
await proxy_logging.alerting_handler(message="x", level="High", alert_type=AlertType.db_exceptions)
proxy_logging.slack_alerting_instance.send_alert.assert_not_called()
@pytest.mark.asyncio
async def test_alerting_handler_sends_to_slack(proxy_logging):
proxy_logging.alerting = ["slack"]
captured: Dict[str, Any] = {}
async def fake_send(**kwargs):
captured.update(kwargs)
proxy_logging.slack_alerting_instance = MagicMock(send_alert=fake_send)
await proxy_logging.alerting_handler(
message="hi", level="High", alert_type=AlertType.db_exceptions, request_data={"metadata": {}}
)
snapshot = {
"message": captured["message"],
"level": captured["level"],
"alert_type": captured["alert_type"],
"user_info": captured["user_info"],
}
assert snapshot == {
"message": "hi",
"level": "High",
"alert_type": AlertType.db_exceptions,
"user_info": None,
}
@pytest.mark.asyncio
async def test_alerting_handler_sentry_without_sdk_error_raises(proxy_logging, monkeypatch):
proxy_logging.alerting = ["sentry"]
monkeypatch.setattr(litellm.utils, "sentry_sdk_instance", None)
with pytest.raises(Exception, match="SENTRY_DSN"):
await proxy_logging.alerting_handler(message="x", level="Low", alert_type=AlertType.db_exceptions)
# ---------------------------------------------------------------------------
# failure_handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_failure_handler_skips_when_db_exceptions_not_in_alert_types(proxy_logging):
proxy_logging.alert_types = ["llm_too_slow"] # type: ignore[list-item]
proxy_logging.alerting_handler = AsyncMock()
proxy_logging.service_logging_obj = MagicMock(async_service_failure_hook=AsyncMock())
await proxy_logging.failure_handler(original_exception=Exception("x"), duration=1.0, call_type="db_read")
proxy_logging.alerting_handler.assert_not_called()
proxy_logging.service_logging_obj.async_service_failure_hook.assert_not_called()
@pytest.mark.asyncio
async def test_failure_handler_logs_db_error_and_calls_service_logging(proxy_logging, monkeypatch):
proxy_logging.alert_types = [AlertType.db_exceptions]
proxy_logging.alerting_handler = AsyncMock()
proxy_logging.service_logging_obj = MagicMock(async_service_failure_hook=AsyncMock())
monkeypatch.setattr(litellm.utils, "capture_exception", None)
await proxy_logging.failure_handler(
original_exception=HTTPException(status_code=500, detail="boom"),
duration=1.5,
call_type="db_write",
)
call_kwargs = proxy_logging.service_logging_obj.async_service_failure_hook.call_args.kwargs
snapshot = {
"service": call_kwargs["service"].value if hasattr(call_kwargs["service"], "value") else call_kwargs["service"],
"duration": call_kwargs["duration"],
"call_type": call_kwargs["call_type"],
}
assert snapshot == {
"service": "postgres",
"duration": 1.5,
"call_type": "db_write",
}
@pytest.mark.asyncio
async def test_failure_handler_with_capture_exception_invoked(proxy_logging, monkeypatch):
proxy_logging.alert_types = [AlertType.db_exceptions]
proxy_logging.alerting_handler = AsyncMock()
proxy_logging.service_logging_obj = MagicMock(async_service_failure_hook=AsyncMock())
captured: Dict[str, Any] = {}
def fake_capture(error):
captured["error"] = error
monkeypatch.setattr(litellm.utils, "capture_exception", fake_capture)
err = RuntimeError("real")
await proxy_logging.failure_handler(original_exception=err, duration=1.0, call_type="db_read")
snapshot = {
"captured_is_input": captured["error"] is err,
"service_failure_called": proxy_logging.service_logging_obj.async_service_failure_hook.called,
"alerting_handler_scheduled": proxy_logging.alerting_handler.called,
}
assert snapshot == {
"captured_is_input": True,
"service_failure_called": True,
"alerting_handler_scheduled": True,
}
@pytest.mark.asyncio
async def test_failure_handler_propagates_service_logging_error_raises(proxy_logging, monkeypatch):
proxy_logging.alert_types = [AlertType.db_exceptions]
proxy_logging.alerting_handler = AsyncMock()
proxy_logging.service_logging_obj = MagicMock(
async_service_failure_hook=AsyncMock(side_effect=RuntimeError("svc"))
)
monkeypatch.setattr(litellm.utils, "capture_exception", None)
with pytest.raises(RuntimeError):
await proxy_logging.failure_handler(
original_exception=Exception("x"), duration=0.0, call_type="db_read"
)