feat(alerts): add management controls and opt-in daily summaries (#51418)

* feat(alerts): add alert controls and daily notification summaries

* fix(alerts): reserve daily notifications before delivery
This commit is contained in:
moe-berri 2026-11-13 02:06:07 +08:00 • committed by GitHub
parent 4ee55a28f7
commit d2711ac559
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1074 additions and 156 deletions

View file

@ -35,6 +35,7 @@ class SendKeyRotatedEmailEvent(WebhookEvent):
class EmailEvent(str, enum.Enum):
model_deprecations = MODEL_DEPRECATION_EMAIL_EVENT
performance_alerts = "Performance Alerts"
virtual_key_created = "Virtual Key Created"
new_user_invitation = "New User Invitation"
virtual_key_rotated = "Virtual Key Rotated"
@ -60,7 +61,8 @@ class DefaultEmailSettings(LiteLLMBaseModel):
settings: dict[EmailEvent, bool] = Field(
default_factory=lambda: {
EmailEvent.model_deprecations: False, # Explicit opt-in for owner retirement reminders.
EmailEvent.model_deprecations: False,
EmailEvent.performance_alerts: False,
EmailEvent.virtual_key_created: True, # On by default
EmailEvent.new_user_invitation: True, # On by default
EmailEvent.virtual_key_rotated: True, # On by default

View file

@ -2,10 +2,11 @@
# Class for sending Slack Alerts #
import asyncio
import datetime
import json
import os
import random
import time
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from datetime import timedelta
from typing import TYPE_CHECKING, Any, Final, Literal
@ -38,6 +39,7 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.table_repositories import InvitationLinkRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
@ -87,6 +89,12 @@ def _proxy_llm_router() -> Router | None:
_DAILY_REPORT_TARGET: Final = "daily_report_schedule"
async def _model_deprecations_enabled() -> bool:
from litellm.proxy.proxy_server import prisma_client
return prisma_client is None or await ConfigRepository(prisma_client, use_writer=True).model_deprecations_enabled()
class SlackAlerting(CustomBatchLogger):
"""
Class for sending Slack Alerts
@ -97,12 +105,13 @@ class SlackAlerting(CustomBatchLogger):
self,
internal_usage_cache: DualCache | None = None,
alerting_threshold: float | None = None, # threshold for slow / hanging llm responses (in seconds)
alerting: list | None = [],
alerting: list[str] | None = [],
alert_types: list[AlertType] = DEFAULT_ALERT_TYPES,
alert_to_webhook_url: dict[AlertType, list[str] | str]
| None = None, # if user wants to separate alerts to diff channels
alerting_args={},
default_webhook_url: str | None = None,
model_deprecations_enabled: Callable[[], Awaitable[bool]] = _model_deprecations_enabled,
alert_type_config: dict[str, dict] | None = None,
async_http_handler: AsyncHTTPHandler | None = None,
**kwargs,
@ -120,6 +129,7 @@ class SlackAlerting(CustomBatchLogger):
self.is_running = False
self.alerting_args = SlackAlertingArgs(**alerting_args)
self.default_webhook_url = default_webhook_url
self.model_deprecations_enabled: Final = model_deprecations_enabled
self.flush_lock = asyncio.Lock()
self.periodic_started = False
self._periodic_flush_task: asyncio.Task[None] | None = None
@ -142,7 +152,7 @@ class SlackAlerting(CustomBatchLogger):
def update_values(
self,
alerting: list | None = None,
alerting: list[str] | None = None,
alerting_threshold: float | None = None,
alert_types: list[AlertType] | None = None,
alert_to_webhook_url: dict[AlertType, list[str] | str] | None = None,
@ -1114,7 +1124,7 @@ Model Info:
The daily lock is claimed only once there is something to say, so an empty pass never blocks a
later real one, and a sent alert is stamped in the shared cache for a day so sibling pods stop asking
"""
if not self._deprecation_alerts_enabled():
if not self._deprecation_alerts_enabled() or not await self.model_deprecations_enabled():
return False
from litellm.proxy.common_utils.model_deprecation import (
@ -1194,6 +1204,26 @@ Model Info:
continue
await asyncio.sleep(DEPRECATION_IDLE_POLL_SECONDS)
def performance_alert_webhooks(self) -> tuple[str, ...]:
if not self.alerting or "slack" not in self.alerting or AlertType.performance_alerts not in self.alert_types:
return ()
configured: Final = (self.alert_to_webhook_url or {}).get(AlertType.performance_alerts)
destination: Final = (
configured
or self.default_webhook_url
or os.getenv("SLACK_WEBHOOK_URL")
or os.getenv("ALERTING_WEBHOOK_URL")
)
if isinstance(destination, list):
return tuple(dict.fromkeys(destination))
return (destination,) if destination else ()
async def send_performance_alert_summary(self, webhook: str, message: str) -> bool:
response: Final = await self.async_http_handler.post(
url=webhook, headers={"Content-Type": "application/json"}, data=json.dumps({"text": message})
)
return response.status_code == 200
async def send_webhook_alert(self, webhook_event: WebhookEvent) -> bool:
"""
Sends structured alert to webhook, if set.

View file

@ -214,9 +214,13 @@ class ModelDeprecationEmailSender:
return True
async def run(self, llm_router: Router, *, now: datetime | None = None) -> int:
if not os.getenv("SMTP_HOST") or not os.getenv("SMTP_SENDER_EMAIL"):
return 0
if not await self.config.model_deprecations_enabled():
return 0
current: Final = now or datetime.now(timezone.utc)
settings: Final = await self.config.get_param("general_settings")
if settings is None or not _OptIn.model_validate(settings.param_value).enabled:
if not _OptIn.model_validate(settings.param_value if settings else {}).enabled:
return 0
base_url: Final = os.getenv("PROXY_BASE_URL", "")
if not base_url:
@ -253,7 +257,7 @@ class ModelDeprecationEmailSender:
async def run_scheduled_model_deprecation_emails() -> None:
"""Poll the email opt-in independently; a large owner mailing never delays Slack checks."""
"""Poll the email settings independently; a large owner mailing never delays Slack checks."""
from litellm._logging import verbose_proxy_logger
while True:

View file

@ -0,0 +1,183 @@
from __future__ import annotations
import asyncio
import hashlib
import html
import os
from collections.abc import Awaitable, Callable, Sequence
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Final
from pydantic import AliasPath, BaseModel, ConfigDict, Field
from litellm._logging import verbose_proxy_logger
from litellm.models.alert import AlertSummary
from litellm.repositories.alert_repository import AlertRepository
from litellm.repositories.config_repository import ConfigRepository
if TYPE_CHECKING:
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
class _EmailSettings(BaseModel):
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
enabled: bool = Field(False, strict=True, validation_alias=AliasPath("email_settings", "Performance Alerts"))
class _Delivery(BaseModel):
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
revision: int = 1
cursor: int
first_window_start: datetime
next_send_at: datetime
lease_until: datetime
def _description(alert: AlertSummary) -> str:
labels: Final = {
"spend": "Spend spike",
"traffic": "Traffic spike",
"latency": "Slower responses",
"reliability": "More failed requests",
}
metric: Final = alert.evidence
def amount(value: float) -> str:
match metric.unit:
case "usd":
return f"${value:,.2f}"
case "ratio":
return f"{value:.1%}"
case "milliseconds":
return f"{value:,.0f} ms"
case "requests":
return f"{value:,.0f} requests"
return f"{labels[alert.type]} for {alert.resource_name}: {amount(metric.value)} vs {amount(metric.baseline)}"
def slack_text(alerts: Sequence[AlertSummary], base_url: str) -> str:
rows: Final = tuple(
f"<{base_url}/ui/alerts?alert={alert.id}|{html.escape(_description(alert), quote=False)}>"
for alert in alerts[:5]
)
return "New performance alerts\n\n" + "\n".join(rows) + f"\n\n<{base_url}/ui/alerts|See all alerts>"
def email_body(alerts: Sequence[AlertSummary], base_url: str) -> tuple[str, str]:
rows: Final = tuple(
f'<li><a href="{html.escape(base_url, quote=True)}/ui/alerts?alert={alert.id}">{html.escape(_description(alert))}</a></li>'
for alert in alerts[:5]
)
text: Final = (
"New performance alerts\n\n"
+ "\n".join(f"{_description(alert)}\n{base_url}/ui/alerts?alert={alert.id}" for alert in alerts[:5])
+ f"\n\nSee all alerts: {base_url}/ui/alerts"
)
return (
f'<h2>New performance alerts</h2><ul>{"".join(rows)}</ul><p><a href="{html.escape(base_url, quote=True)}/ui/alerts">See all alerts</a></p>',
text,
)
class PerformanceAlertNotifier:
def __init__(self, config: ConfigRepository, alerts: AlertRepository) -> None:
self.config: Final = config
self.alerts: Final = alerts
async def deliver(
self,
destination: str,
send: Callable[[Sequence[AlertSummary], str], Awaitable[bool]],
now: datetime,
) -> None:
key: Final = "performance_alert_digest:" + hashlib.sha256(destination.encode()).hexdigest()
row: Final = await self.config.get_param(key)
previous: Final = (
_Delivery.model_validate(row.param_value)
if row
else _Delivery(
revision=0,
cursor=0,
first_window_start=now - timedelta(days=1),
next_send_at=now,
lease_until=now,
)
)
if previous.next_send_at > now or previous.lease_until > now:
return
through: Final = await self.alerts.latest_id()
found: Final = await self.alerts.summaries(previous.cursor, through)
alerts: Final = (
found
if previous.cursor
else tuple(alert for alert in found if alert.created_at >= previous.first_window_start)
)
claim: Final = previous.model_copy(
update={
"revision": previous.revision + 1,
"lease_until": now + timedelta(minutes=2),
"next_send_at": now + timedelta(days=1) if alerts else now,
}
)
if not await self.config.set_param_if_revision(key, claim.model_dump(mode="json"), previous.revision):
return
if alerts and not await send(alerts, f"{key}-{through}"):
return
sent: Final = claim.model_copy(
update={
"revision": claim.revision + 1,
"cursor": through,
"next_send_at": now + timedelta(days=1) if alerts else now,
"lease_until": now,
}
)
if not await self.config.set_param_if_revision(key, sent.model_dump(mode="json"), claim.revision):
verbose_proxy_logger.warning("Could not record performance alert summary delivery")
async def run(self, slack: SlackAlerting | None, *, now: datetime | None = None) -> None:
from litellm.proxy.utils import send_email
base_url: Final = os.getenv("PROXY_BASE_URL", "").rstrip("/")
if not base_url:
return
if slack is not None:
for webhook in slack.performance_alert_webhooks():
async def send_slack(alerts: Sequence[AlertSummary], receipt: str, webhook: str = webhook) -> bool:
return await slack.send_performance_alert_summary(webhook, slack_text(alerts, base_url))
await self._try_deliver("slack:" + webhook, send_slack, now or datetime.now(timezone.utc))
if not os.getenv("SMTP_HOST") or not os.getenv("SMTP_SENDER_EMAIL"):
return
settings: Final = await self.config.get_param("general_settings")
if not _EmailSettings.model_validate(settings.param_value if settings else {}).enabled:
return
after: str = "" # rebind-ok: advances the bounded recipient page
while recipients := await self.alerts.recipients(after):
for recipient in recipients:
async def send_mail(
alerts: Sequence[AlertSummary], receipt: str, address: str = recipient.email
) -> bool:
body, text = email_body(alerts, base_url)
return await send_email(
receiver_email=address,
subject="New performance alerts",
html=body,
text=text,
message_id=f"<{receipt}@alerts.litellm.ai>",
)
await self._try_deliver("email:" + recipient.email, send_mail, now or datetime.now(timezone.utc))
after = recipients[-1].user_id
await asyncio.sleep(0.1)
async def _try_deliver(
self, destination: str, send: Callable[[Sequence[AlertSummary], str], Awaitable[bool]], now: datetime
) -> None:
try:
await asyncio.wait_for(self.deliver(destination, send, now), timeout=60)
except Exception as exc: # noqa: BLE001 # one unavailable destination must not stop the other recipients
verbose_proxy_logger.warning("Performance alert summary delivery failed (%s)", type(exc).__name__)

View file

@ -41,6 +41,24 @@ class DeprecationEvidence(BaseModel):
)
class AlertSummary(BaseModel):
model_config = ConfigDict(frozen=True)
id: AlertID
type: AlertType
resource_type: AlertResourceType
resource_name: str
created_at: AwareDatetime
evidence: MetricEvidence
class AlertRecipient(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: str
email: str
class AlertDetection(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")

View file

@ -85,6 +85,7 @@ class AlertMonitoring(BaseModel):
model_config = ConfigDict(frozen=True)
enabled: bool = Field(description="Whether performance monitoring is enabled. Defaults to true.")
model_deprecations_enabled: bool = Field(default=True, description="Whether model deprecation alerts are enabled.")
alerts_used: int = Field(ge=0, le=FREE_ALERT_LIMIT, description="Lifetime free allowance consumed, capped at five.")
alert_limit: int | None = Field(description="Free allowance, or null for Enterprise.")
disabled_by_config: bool = Field(description="Whether performance_alerts_enabled is false in proxy settings.")
@ -100,9 +101,16 @@ class AlertMonitoringUpdate(BaseModel):
enabled: bool = Field(strict=True, description="Enable or disable performance monitoring for this gateway.")
class ModelDeprecationMonitoringUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
model_deprecations_enabled: bool = Field(strict=True, description="Enable or disable model deprecation alerts.")
class AlertMonitoringStore(Protocol):
async def monitoring(self) -> AlertMonitoring: ...
async def set_enabled(self, enabled: bool) -> AlertMonitoring: ...
async def set_model_deprecations_enabled(self, enabled: bool) -> AlertMonitoring: ...
class PerformanceAlertStore(Protocol):

View file

@ -12,7 +12,7 @@ from litellm.models.alert import (
AlertView,
AlertViewPage,
)
from litellm.models.performance_alert import AlertMonitoring, AlertMonitoringUpdate
from litellm.models.performance_alert import AlertMonitoring, AlertMonitoringUpdate, ModelDeprecationMonitoringUpdate
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.deps.repositories import AlertMonitoringRepositoryDep, AlertRepositoryDep
@ -60,7 +60,7 @@ async def list_alerts(
return AlertViewPage(data=await store.enrich(page.data), next_cursor=page.next_cursor)
@router.get("/alerts/monitoring", response_model=AlertMonitoring, summary="Read performance monitoring settings")
@router.get("/alerts/monitoring", response_model=AlertMonitoring, summary="Read alert monitoring settings")
async def get_monitoring(user: Auth, repository: AlertMonitoringRepositoryDep) -> AlertMonitoring:
"""Read the gateway's opt-out setting and free allowance. Requires a gateway admin or view-only admin."""
_authorize(user, write=False)
@ -69,14 +69,16 @@ async def get_monitoring(user: Auth, repository: AlertMonitoringRepositoryDep) -
return await repository.monitoring()
@router.patch("/alerts/monitoring", response_model=AlertMonitoring, summary="Enable or disable performance monitoring")
@router.patch("/alerts/monitoring", response_model=AlertMonitoring, summary="Enable or disable alert monitoring")
async def update_monitoring(
data: AlertMonitoringUpdate, user: Auth, repository: AlertMonitoringRepositoryDep
data: AlertMonitoringUpdate | ModelDeprecationMonitoringUpdate, user: Auth, repository: AlertMonitoringRepositoryDep
) -> AlertMonitoring:
"""Change monitoring for every worker using this database. Only gateway admins can write. Keeps saved alerts."""
_authorize(user, write=True)
if repository is None:
raise HTTPException(status_code=503, detail="Alerts require a database connection")
if isinstance(data, ModelDeprecationMonitoringUpdate):
return await repository.set_model_deprecations_enabled(data.model_deprecations_enabled)
if data.enabled and (await repository.monitoring()).disabled_by_config:
raise HTTPException(status_code=409, detail="Performance monitoring is disabled in proxy settings")
return await repository.set_enabled(data.enabled)

View file

@ -11948,6 +11948,35 @@ class ProxyStartupEvent:
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
async def send_performance_alert_summaries() -> None:
from litellm.integrations.performance_alert_notifications import PerformanceAlertNotifier
from litellm.repositories.alert_repository import AlertRepository
slack: Final = proxy_logging_obj.slack_alerting_instance
email_connected: Final = bool(os.getenv("SMTP_HOST") and os.getenv("SMTP_SENDER_EMAIL"))
if not email_connected and not slack.performance_alert_webhooks():
return
if _current_general_settings().get("performance_alerts_enabled", True) is not True:
return
if not (
await PerformanceAlertRepository(prisma_client, enterprise=premium_user is True).monitoring()
).enabled:
return
await PerformanceAlertNotifier(
ConfigRepository(prisma_client, use_writer=True), AlertRepository(prisma_client)
).run(slack)
scheduler.add_job( # pyright: ignore[reportUnknownMemberType] # APScheduler does not provide typed job methods
send_performance_alert_summaries,
"interval",
minutes=5,
next_run_time=datetime.now(timezone.utc),
id="performance_alert_summaries",
replace_existing=True,
max_instances=1,
coalesce=True,
)
### PTU DAILY ROLLUP ###
from litellm.proxy.spend_tracking.ptu_feature_flag import (
is_ptu_cost_attribution_enabled,

View file

@ -21,8 +21,10 @@ from litellm.models.alert import (
AlertFilter,
AlertHistoryPoint,
AlertPage,
AlertRecipient,
AlertResourceType,
AlertStatus,
AlertSummary,
AlertView,
)
from litellm.repositories.prisma_protocols import PrismaRecord, TableActions
@ -64,6 +66,17 @@ class _HistoryRow(BaseModel):
_HISTORY_ROWS: Final = TypeAdapter(tuple[_HistoryRow, ...])
_SUMMARIES: Final = TypeAdapter(tuple[AlertSummary, ...])
_RECIPIENTS: Final = TypeAdapter(tuple[AlertRecipient, ...])
class _LatestAlert(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
_LATEST_ALERT: Final = TypeAdapter(tuple[_LatestAlert, ...])
_HISTORY_SOURCES: Final[
Mapping[AlertResourceType, tuple[LiteralString, LiteralString, LiteralString, LiteralString, LiteralString]]
] = {
@ -158,6 +171,45 @@ class AlertRepository:
TableActions[PrismaRecord], prisma_client.writer_db.litellm_alert
)
async def latest_id(self) -> int:
async with self._database.tx(timeout=timedelta(seconds=5)) as transaction:
await transaction.query_raw("SELECT set_config('statement_timeout', '2s', true)")
rows: Final = _LATEST_ALERT.validate_python(
await transaction.query_raw('SELECT COALESCE(MAX(id), 0) AS id FROM "LiteLLM_Alert"')
)
return rows[0].id
async def recipients(self, after: str) -> tuple[AlertRecipient, ...]:
async with self._database.tx(timeout=timedelta(seconds=5)) as transaction:
await transaction.query_raw("SELECT set_config('statement_timeout', '2s', true)")
rows: Final = await transaction.query_raw(
"""SELECT user_id, LOWER(TRIM(user_email)) AS email FROM "LiteLLM_UserTable"
WHERE user_role = 'proxy_admin' AND user_id > $1 AND LENGTH(TRIM(user_email)) BETWEEN 3 AND 320
ORDER BY user_id LIMIT 100""",
after,
)
return _RECIPIENTS.validate_python(rows)
async def summaries(self, after: int, through: int) -> tuple[AlertSummary, ...]:
async with self._database.tx(timeout=timedelta(seconds=5)) as transaction:
await transaction.query_raw("SELECT set_config('statement_timeout', '2s', true)")
rows: Final = await transaction.query_raw(
"""SELECT a.id::text, a.type, a.resource_type, a.created_at AT TIME ZONE 'UTC' AS created_at,
LEFT(COALESCE(k.key_alias, t.team_alias, u.user_alias,
CASE WHEN a.resource_type = 'model' THEN a.resource_id ELSE a.resource_type END), 120) AS resource_name,
jsonb_build_object('value', a.evidence->'value', 'baseline', a.evidence->'baseline',
'unit', a.evidence->'unit', 'sample_size', a.evidence->'sample_size') AS evidence
FROM "LiteLLM_Alert" a
LEFT JOIN "LiteLLM_VerificationToken" k ON a.resource_type = 'key' AND k.token = a.resource_id
LEFT JOIN "LiteLLM_TeamTable" t ON a.resource_type = 'team' AND t.team_id = a.resource_id
LEFT JOIN "LiteLLM_UserTable" u ON a.resource_type = 'user' AND u.user_id = a.resource_id
WHERE a.id > $1 AND a.id <= $2 AND a.status = 'open' AND a.type != 'model_deprecation'
ORDER BY a.id DESC LIMIT 5""",
after,
through,
)
return _SUMMARIES.validate_python(rows)
async def record(self, detection: AlertDetection) -> Alert:
from prisma import Json
from prisma.errors import UniqueViolationError

View file

@ -60,6 +60,7 @@ ROUTER_SETTINGS_PARAM: Final = "router_settings"
_JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_JSON_RULES: Final = TypeAdapter(list[JsonValue])
_ROUTER_SETTINGS: Final = TypeAdapter(dict[str, JsonValue])
_BOOL: Final = TypeAdapter(bool)
def _stored_router_settings(param_value: object) -> Mapping[str, JsonValue]:
@ -138,6 +139,13 @@ class ConfigRepository:
)
return ConfigParam(param_name=param_name, param_value=param_value)
async def model_deprecations_enabled(self) -> bool:
record: Final = await self.get_param("model_deprecation_alerts_enabled")
return True if record is None else _BOOL.validate_python(record.param_value, strict=True)
async def set_model_deprecations_enabled(self, enabled: bool) -> None:
await self.set_param("model_deprecation_alerts_enabled", enabled)
async def set_param_if_revision(self, param_name: str, param_value: object, revision: int) -> bool:
rows: Final = await self._writer.query_raw(
"""INSERT INTO "LiteLLM_Config" (param_name, param_value, last_run_at)

View file

@ -24,6 +24,7 @@ from litellm.models.performance_alert import (
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.repositories.alert_repository import record_alerts
from litellm.repositories.config_repository import ConfigRepository
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
@ -161,6 +162,7 @@ class PerformanceAlertRepository:
)
self._enterprise: Final = enterprise
self._configured: Final = configured
self._config: Final = ConfigRepository(prisma_client, use_writer=True)
async def _monitoring(self, transaction: _Transaction, *, lock: bool = False) -> AlertMonitoring:
query: Final = 'SELECT enabled, alerts_used FROM "LiteLLM_PerformanceAlertState" WHERE id = 1'
@ -190,7 +192,14 @@ class PerformanceAlertRepository:
async def monitoring(self) -> AlertMonitoring:
async with self._transaction() as transaction:
return await self._monitoring(transaction)
monitoring: Final = await self._monitoring(transaction)
return monitoring.model_copy(
update={"model_deprecations_enabled": await self._config.model_deprecations_enabled()}
)
async def set_model_deprecations_enabled(self, enabled: bool) -> AlertMonitoring:
await self._config.set_model_deprecations_enabled(enabled)
return await self.monitoring()
async def set_enabled(self, enabled: bool) -> AlertMonitoring:
async with self._database.tx(timeout=timedelta(seconds=10)) as transaction:
@ -203,7 +212,7 @@ class PerformanceAlertRepository:
updated_at = now() AT TIME ZONE 'UTC' WHERE id = 1""",
enabled,
)
return await self._monitoring(transaction)
return await self.monitoring()
@asynccontextmanager
async def _transaction(self) -> AsyncGenerator[_Transaction]:

View file

@ -188,6 +188,7 @@ class AlertType(str, Enum):
cooldown_deployment = "cooldown_deployment"
new_model_added = "new_model_added"
model_deprecation_warnings = "model_deprecation_warnings"
performance_alerts = "performance_alerts"
# Outage alerts
outage_alerts = "outage_alerts"

View file

@ -27,7 +27,13 @@ const calls: { path: string; query: URLSearchParams; method: string; body: unkno
let status = "open";
let rejectWrite = false;
let rejectHistory = false;
let monitoring = { enabled: true, alerts_used: 0, alert_limit: 5 as number | null, disabled_by_config: false };
let monitoring = {
model_deprecations_enabled: true,
enabled: true,
alerts_used: 0,
alert_limit: 5 as number | null,
disabled_by_config: false,
};
let rejectMonitoring = false;
function response(data: unknown, code = 200) {
return new Response(JSON.stringify(data), { status: code, headers: { "Content-Type": "application/json" } });
@ -41,7 +47,13 @@ describe("Alerts", () => {
rejectWrite = false;
rejectHistory = false;
rejectMonitoring = false;
monitoring = { enabled: true, alerts_used: 0, alert_limit: 5, disabled_by_config: false };
monitoring = {
model_deprecations_enabled: true,
enabled: true,
alerts_used: 0,
alert_limit: 5,
disabled_by_config: false,
};
role = "proxy_admin";
document.cookie = `token=${btoa(JSON.stringify({ alg: "none" }))}.${btoa(JSON.stringify({ key: "example-session", exp: 4102444800 }))}.example; path=/`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
@ -57,7 +69,7 @@ describe("Alerts", () => {
if (rejectMonitoring) return response({ detail: "Unavailable" }, 503);
if (request.method === "PATCH") {
if (rejectWrite) return response({ detail: "Unavailable" }, 503);
monitoring = { ...monitoring, enabled: body.enabled };
monitoring = { ...monitoring, ...body };
}
return response(monitoring);
}
@ -144,7 +156,7 @@ describe("Alerts", () => {
expect(await screen.findByText("No resolved alerts.")).toBeInTheDocument();
});
it("keeps deprecation email setup accessible from the inbox", async () => {
it("keeps alert email setup accessible from the inbox", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await user.click(await screen.findByRole("button", { name: "Set up email reminders" }));
@ -161,12 +173,36 @@ describe("Alerts", () => {
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mark resolved" })).not.toBeInTheDocument();
});
it("keeps notification opt-in separate from the model deprecation toggle", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />);
expect(
await screen.findByText(
"Model deprecation alerts are unlimited. Performance monitoring includes 5 free alerts, then requires Enterprise.",
),
).toBeInTheDocument();
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Manage alerts" }));
const toggle = await screen.findByRole("switch", { name: "Model deprecations" });
expect(toggle).toBeChecked();
await user.click(toggle);
await waitFor(() => expect(toggle).not.toBeChecked());
expect(calls.filter((call) => call.method === "PATCH")).toEqual([
expect.objectContaining({ path: "/alerts/monitoring", body: { model_deprecations_enabled: false } }),
]);
expect(screen.getByRole("switch", { name: "Performance monitoring" })).toBeChecked();
expect(screen.getByText(/Opt in to Performance Alerts in email or Slack settings/)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(await screen.findByRole("button", { name: /Spend on Research agent/ })).toBeInTheDocument();
});
it("defaults on and persists opting out through the monitoring API", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
const toggle = await screen.findByRole("switch", { name: "Performance monitoring" });
expect(toggle).toBeChecked();
expect(screen.getByText("0 of 5 free alerts used.")).toBeInTheDocument();
expect(screen.getByText("0 of 5 free alerts generated.")).toBeInTheDocument();
await user.click(toggle);
expect(await screen.findByText("Off. Saved alerts remain available.")).toBeInTheDocument();
expect(toggle).not.toBeChecked();
@ -174,15 +210,15 @@ describe("Alerts", () => {
path: "/alerts/monitoring",
body: { enabled: false },
});
expect(screen.getByRole("button", { name: /Spend on Research agent/ })).toBeInTheDocument();
await user.click(toggle);
expect(await screen.findByText("0 of 5 free alerts used.")).toBeInTheDocument();
expect(await screen.findByText("0 of 5 free alerts generated.")).toBeInTheDocument();
});
it("explains exhaustion without blocking saved alerts or model deprecations", async () => {
monitoring.alerts_used = 5;
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
expect(
await screen.findByText("You’ve used all 5 free alerts. New performance alerts are paused."),
).toBeInTheDocument();
@ -190,8 +226,6 @@ describe("Alerts", () => {
"href",
"https://www.litellm.ai/enterprise",
);
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Set up email reminders" })).toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Performance monitoring" }));
expect(await screen.findByText("Off. Saved alerts remain available.")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Upgrade to Enterprise" })).not.toBeInTheDocument();
@ -200,6 +234,7 @@ describe("Alerts", () => {
it("keeps the saved toggle value when an older refresh finishes later", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
const toggle = await screen.findByRole("switch", { name: "Performance monitoring" });
const originalFetch = vi.mocked(globalThis.fetch).getMockImplementation()!;
const staleResponse = response(monitoring);
@ -211,7 +246,9 @@ describe("Alerts", () => {
}
return originalFetch(input, init);
});
await user.click(screen.getByRole("button", { name: "Refresh" }));
await act(async () => {
void testQueryClient.invalidateQueries({ queryKey: ["alert-monitoring"] });
});
await user.click(toggle);
expect(await screen.findByText("Off. Saved alerts remain available.")).toBeInTheDocument();
await act(async () => {
@ -227,6 +264,7 @@ describe("Alerts", () => {
monitoring.alert_limit = null;
monitoring.alerts_used = 5;
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
expect(await screen.findByText("On with Enterprise.")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Upgrade to Enterprise" })).not.toBeInTheDocument();
});
@ -236,22 +274,26 @@ describe("Alerts", () => {
monitoring.alerts_used = 5;
monitoring.enabled = false;
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
expect(await screen.findByText("Disabled in proxy settings.")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Upgrade to Enterprise" })).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Performance monitoring" })).toHaveAttribute("aria-disabled", "true");
});
it("lets viewers see monitoring status without a toggle", async () => {
it("lets viewers inspect alert settings without changing them", async () => {
role = "proxy_admin_viewer";
renderWithProviders(<Alerts />);
expect(await screen.findByText("0 of 5 free alerts used.")).toBeInTheDocument();
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
expect(await screen.findByText("0 of 5 free alerts generated.")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Performance monitoring" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("switch", { name: "Model deprecations" })).toHaveAttribute("aria-disabled", "true");
});
it("keeps the saved preference when a monitoring change fails", async () => {
rejectWrite = true;
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
await user.click(await screen.findByRole("switch", { name: "Performance monitoring" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save monitoring settings");
expect(screen.getByRole("switch", { name: "Performance monitoring" })).toBeChecked();
@ -261,10 +303,12 @@ describe("Alerts", () => {
rejectMonitoring = true;
const user = userEvent.setup();
renderWithProviders(<Alerts />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load monitoring settings");
expect(screen.getByRole("button", { name: /Spend on Research agent/ })).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Manage alerts" }));
expect(await within(screen.getByRole("dialog")).findByRole("alert")).toHaveTextContent(
"Could not load monitoring settings",
);
rejectMonitoring = false;
await user.click(screen.getByRole("button", { name: "Retry" }));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("switch", { name: "Performance monitoring" })).toBeChecked();
});
});

View file

@ -18,7 +18,7 @@ import { ALERT_TYPES, AlertFilter, AlertItem, TYPE_LABEL_KEYS, presentAlert } fr
import AlertActions from "./AlertActions";
import AlertChart from "./AlertChart";
import AlertDetail from "./AlertDetail";
import PerformanceMonitoring from "./PerformanceMonitoring";
import ManageAlerts from "./ManageAlerts";
const parsers = {
alert: parseAsString,
@ -123,8 +123,7 @@ function AlertsInbox({ auth }: { auth: ReturnType<typeof useAuthorized> }) {
}
return (
<>
<h1 className="text-2xl font-semibold">{t("alerts.unified.title")}</h1>
<PerformanceMonitoring scope={scope} readOnly={Boolean(auth.isViewOnly)} />
<ManageAlerts scope={scope} readOnly={Boolean(auth.isViewOnly)} />
<PageTabs
value={params.state}
onValueChange={(value) => void setParams({ state: value as AlertStatus })}
@ -205,7 +204,7 @@ function AlertsInbox({ auth }: { auth: ReturnType<typeof useAuthorized> }) {
{list.isFetchingNextPage ? t("alerts.unified.loadingMore") : t("alerts.unified.loadMore")}
</Button>
)}
{showDeprecations && <DeprecationEmailSetup />}
<DeprecationEmailSetup />
</>
);
}

View file

@ -0,0 +1,150 @@
import { Settings } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { uiHref } from "@/utils/uiHref";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { backgroundFetchClient } from "@/lib/http/api";
import { getProxyBaseUrl } from "@/components/networking";
import type { components } from "@/lib/http/schema";
function statusMessage(data: components["schemas"]["AlertMonitoring"]) {
if (data.disabled_by_config) return "alerts.monitoring.disabledByConfig";
if (!data.enabled) return "alerts.monitoring.off";
if (data.alert_limit === null) return "alerts.monitoring.enterprise";
if (data.alerts_used >= data.alert_limit) return "alerts.monitoring.exhausted";
return "alerts.monitoring.allowance";
}
export default function ManageAlerts({ scope, readOnly }: { scope: string; readOnly: boolean }) {
const { t } = useTranslation();
const client = useQueryClient();
const queryKey = ["alert-monitoring", getProxyBaseUrl(), scope];
const queryOptions = {
queryKey,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data } = await backgroundFetchClient.GET("/alerts/monitoring", { signal });
if (!data) throw new Error("Could not load monitoring settings");
return data;
},
staleTime: 60_000,
retry: false,
};
const query = useQuery(queryOptions);
const mutation = useMutation({
mutationFn: async (
body: components["schemas"]["AlertMonitoringUpdate"] | components["schemas"]["ModelDeprecationMonitoringUpdate"],
) => {
const { data } = await backgroundFetchClient.PATCH("/alerts/monitoring", { body });
if (!data) throw new Error("Could not save monitoring settings");
return data;
},
onSuccess: async (data) => {
await client.cancelQueries({ queryKey });
client.setQueryData(queryKey, data);
await client.invalidateQueries({ queryKey: ["deprecationAlerts"] });
},
});
const data = query.data;
const messageKey = data ? statusMessage(data) : undefined;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">{t("alerts.unified.title")}</h1>
<Dialog>
<DialogTrigger render={<Button variant="outline" size="sm" />}>
<Settings className="size-4" aria-hidden />
{t("alerts.monitoring.manage")}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("alerts.monitoring.manage")}</DialogTitle>
<DialogDescription>{t("alerts.monitoring.description")}</DialogDescription>
</DialogHeader>
{query.isError && (
<div role="alert" className="space-y-2 text-sm">
<p>{t("alerts.monitoring.loadError")}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>
{t("alerts.panel.retry")}
</Button>
</div>
)}
{!query.isError && !data && (
<p role="status" className="text-sm text-muted-foreground">
{t("alerts.monitoring.loading")}
</p>
)}
{!query.isError && data && (
<div className="divide-y divide-border">
<div className="flex items-start justify-between gap-6 py-4">
<div className="space-y-1">
<label htmlFor="model-deprecations-monitoring" className="text-sm font-medium">
{t("alerts.monitoring.deprecations")}
</label>
<p className="text-sm text-muted-foreground">{t("alerts.monitoring.deprecationsDescription")}</p>
</div>
<Switch
id="model-deprecations-monitoring"
checked={data.model_deprecations_enabled}
disabled={readOnly || mutation.isPending}
onCheckedChange={(enabled) => mutation.mutate({ model_deprecations_enabled: enabled })}
/>
</div>
<div className="flex items-start justify-between gap-6 py-4">
<div className="space-y-1">
<label htmlFor="performance-monitoring" className="text-sm font-medium">
{t("alerts.monitoring.title")}
</label>
<p id="performance-monitoring-status" className="text-sm text-muted-foreground" role="status">
{messageKey && t(messageKey, { used: data.alerts_used, limit: data.alert_limit })}
{messageKey === "alerts.monitoring.exhausted" && (
<a
className="ml-2 text-foreground underline underline-offset-4"
href="https://www.litellm.ai/enterprise"
target="_blank"
rel="noopener noreferrer"
>
{t("alerts.monitoring.upgrade")}
</a>
)}
</p>
</div>
<Switch
id="performance-monitoring"
checked={data.enabled}
disabled={readOnly || mutation.isPending || data.disabled_by_config}
onCheckedChange={(enabled) => mutation.mutate({ enabled })}
aria-describedby="performance-monitoring-status"
/>
</div>
</div>
)}
{mutation.isError && (
<p role="alert" className="text-sm text-destructive">
{t("alerts.monitoring.saveError")}
</p>
)}
{readOnly && <p className="text-sm text-muted-foreground">{t("alerts.monitoring.adminOnly")}</p>}
<p className="text-sm text-muted-foreground">{t("alerts.monitoring.notifications")}</p>
<a href={uiHref("logging-and-alerts")} className="text-sm underline underline-offset-4">
{t("alerts.monitoring.connections")}
</a>
</DialogContent>
</Dialog>
</div>
{data && (
<p className="text-xs text-muted-foreground">
{t(data.alert_limit === null ? "alerts.monitoring.enterpriseNote" : "alerts.monitoring.freeNote")}
</p>
)}
</div>
);
}

View file

@ -1,99 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { backgroundFetchClient } from "@/lib/http/api";
import { getProxyBaseUrl } from "@/components/networking";
import type { components } from "@/lib/http/schema";
function statusMessage(data: components["schemas"]["AlertMonitoring"]) {
if (data.disabled_by_config) return "alerts.monitoring.disabledByConfig";
if (!data.enabled) return "alerts.monitoring.off";
if (data.alert_limit === null) return "alerts.monitoring.enterprise";
if (data.alerts_used >= data.alert_limit) return "alerts.monitoring.exhausted";
return "alerts.monitoring.allowance";
}
export default function PerformanceMonitoring({ scope, readOnly }: { scope: string; readOnly: boolean }) {
const { t } = useTranslation();
const client = useQueryClient();
const queryKey = ["alert-monitoring", getProxyBaseUrl(), scope];
const queryOptions = {
queryKey,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data } = await backgroundFetchClient.GET("/alerts/monitoring", { signal });
if (!data) throw new Error("Could not load monitoring settings");
return data;
},
staleTime: 60_000,
retry: false,
};
const query = useQuery(queryOptions);
const mutation = useMutation({
mutationFn: async (enabled: boolean) => {
const { data } = await backgroundFetchClient.PATCH("/alerts/monitoring", { body: { enabled } });
if (!data) throw new Error("Could not save monitoring settings");
return data;
},
onSuccess: async (data) => {
await client.cancelQueries({ queryKey });
client.setQueryData(queryKey, data);
},
});
if (query.isError)
return (
<div role="alert" className="flex flex-wrap items-center gap-2 text-sm">
<p>{t("alerts.monitoring.loadError")}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>
{t("alerts.panel.retry")}
</Button>
</div>
);
if (!query.data)
return (
<p role="status" className="text-sm text-muted-foreground">
{t("alerts.monitoring.loading")}
</p>
);
const data = query.data;
const messageKey = statusMessage(data);
const message = t(messageKey, { used: data.alerts_used, limit: data.alert_limit });
return (
<div className="space-y-2">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-3">
<label htmlFor="performance-monitoring" className="font-medium">
{t("alerts.monitoring.title")}
</label>
{!readOnly && (
<Switch
id="performance-monitoring"
checked={data.enabled}
disabled={mutation.isPending || data.disabled_by_config}
onCheckedChange={(enabled) => mutation.mutate(enabled)}
aria-describedby="performance-monitoring-status"
/>
)}
</div>
<p id="performance-monitoring-status" className="text-muted-foreground" role="status">
{message}
{messageKey === "alerts.monitoring.exhausted" && (
<a
className="ml-2 text-foreground underline underline-offset-4"
href="https://www.litellm.ai/enterprise"
target="_blank"
rel="noopener noreferrer"
>
{t("alerts.monitoring.upgrade")}
</a>
)}
</p>
</div>
{mutation.isError && (
<p role="alert" className="text-sm text-destructive">
{t("alerts.monitoring.saveError")}
</p>
)}
</div>
);
}

View file

@ -45,6 +45,16 @@ beforeEach(() => {
if (url.endsWith("/management/v1/users/me")) {
return Response.json({ user_id: "example-admin", user_role: role });
}
if (url.endsWith("/alerts/monitoring")) {
const monitoring = {
enabled: true,
model_deprecations_enabled: true,
alerts_used: 0,
alert_limit: 5,
disabled_by_config: false,
};
return Response.json(monitoring);
}
if (url.endsWith("/model/deprecations?warn_within_days=30")) {
if (rejectDeprecations) return Response.json({ detail: "Unavailable" }, { status: 503 });
return Response.json({ deprecated: [], imminent: [] });

View file

@ -13,11 +13,11 @@ describe("deprecation email setup", () => {
const user = userEvent.setup();
render(<DeprecationEmailSetup />);
await user.click(screen.getByRole("button", { name: "Set up email reminders" }));
expect(await screen.findByRole("dialog", { name: "Set up model deprecation emails" })).toBeInTheDocument();
expect(await screen.findByRole("dialog", { name: "Set up alert emails" })).toBeInTheDocument();
expect(screen.getByText("Connect email delivery")).toBeInTheDocument();
expect(screen.getByText("Enable model deprecation emails")).toBeInTheDocument();
expect(screen.getByText("Owners get two reminders")).toBeInTheDocument();
expect(screen.getByText(/This is off by default/)).toBeInTheDocument();
expect(screen.getByText("Choose your email alerts")).toBeInTheDocument();
expect(screen.getByText("Know what gets sent")).toBeInTheDocument();
expect(screen.getByText(/Both are off by default/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open Email Settings" })).toHaveAttribute(
"href",
"/ui/logging-and-alerts?tab=email-alerts",

View file

@ -19,6 +19,15 @@ describe("deprecation alert API wiring", () => {
vi.clearAllMocks();
testQueryClient.clear();
});
it("stops loading usage and acknowledgements when model deprecations are disabled", async () => {
vi.mocked(useCurrentUser).mockReturnValue({ data: { user_id: "admin", user_role: "proxy_admin" } } as ReturnType<
typeof useCurrentUser
>);
vi.mocked(backgroundFetchClient.GET).mockResolvedValue({ data: { model_deprecations_enabled: false } } as never);
renderWithProviders(<AlertsHost />);
expect(await screen.findByText("0 alerts")).toBeInTheDocument();
expect(backgroundFetchClient.GET).toHaveBeenCalledExactlyOnceWith("/alerts/monitoring", expect.anything());
});
it("should avoid querying gateway-wide usage for non-admins", () => {
vi.mocked(useCurrentUser).mockReturnValue({
data: { user_id: "internal", user_role: "internal_user" },
@ -36,7 +45,7 @@ describe("deprecation alert API wiring", () => {
} as never);
renderWithProviders(<AlertsHost />);
expect(await screen.findByText("0 alerts")).toBeInTheDocument();
expect(backgroundFetchClient.GET).toHaveBeenCalledTimes(1);
expect(backgroundFetchClient.GET).toHaveBeenCalledTimes(2);
expect(backgroundFetchClient.GET).toHaveBeenCalledWith(
"/model/deprecations",
expect.objectContaining({ params: { query: { warn_within_days: 30 } } }),
@ -59,6 +68,7 @@ describe("deprecation alert API wiring", () => {
],
};
vi.mocked(backgroundFetchClient.GET)
.mockResolvedValueOnce({ data: { model_deprecations_enabled: true } } as never)
.mockResolvedValueOnce({ data: schedule } as never)
.mockResolvedValueOnce({ data: activity } as never)
.mockResolvedValueOnce({ data: { status: "open" } } as never);

View file

@ -17,6 +17,9 @@ export function useDeprecationAlerts(accessToken: string | null) {
queryKey: ["deprecationAlerts", scope, range],
enabled,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data: monitoring } = await backgroundFetchClient.GET("/alerts/monitoring", { signal });
if (!monitoring) throw new Error("Alert settings are unavailable");
if (monitoring.model_deprecations_enabled === false) return [];
const { data: schedule } = await backgroundFetchClient.GET("/model/deprecations", {
params: { query: { warn_within_days: 30 } },
signal,

View file

@ -74,6 +74,9 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
// Helper function to get a description for each event type
const getEventDescription = (event: EmailEvent): string => {
if (event === "Performance Alerts") {
return t("loggingAndAlerts.emailEvents.descriptions.performanceAlerts");
}
if (event === "Model Deprecations") {
return t("loggingAndAlerts.emailEvents.descriptions.modelDeprecations");
}

View file

@ -421,6 +421,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
outage_alerts: "loggingAndAlerts.alertTypes.labels.outageAlerts",
region_outage_alerts: "loggingAndAlerts.alertTypes.labels.regionOutageAlerts",
model_deprecation_warnings: "loggingAndAlerts.alertTypes.labels.modelDeprecationWarnings",
performance_alerts: "loggingAndAlerts.alertTypes.labels.performanceAlerts",
};
useEffect(() => {

View file

@ -5938,18 +5938,18 @@
"description": "Add your SMTP settings and public gateway URL in Email Settings. Send a test email to verify delivery."
},
"enable": {
"title": "Enable model deprecation emails",
"description": "In Email events, turn on Model Deprecations and save. This is off by default."
"title": "Choose your email alerts",
"description": "In Email events, enable Model Deprecations, Performance Alerts, or both, then save. Both are off by default."
},
"reminders": {
"title": "Owners get two reminders",
"description": "Affected key owners and team admins receive a reminder 30 days before retirement, and one final reminder 1 day before. Recipients are based on actual usage in the last 30 days. Dates are checked in UTC."
"title": "Know what gets sent",
"description": "Model deprecations notify affected key owners and team admins 30 days and 1 day before retirement. Performance alerts send gateway admins one daily summary when there are new findings, with up to five alerts and a link to the rest."
}
},
"summary": "Give affected owners time to migrate. Email reminders at 30 days and 1 day.",
"summary": "Get model retirement reminders and daily performance summaries by email.",
"trigger": "Set up email reminders",
"title": "Set up model deprecation emails",
"description": "Help key owners and team admins act before a provider retires a model. Only their affected usage is included.",
"title": "Set up alert emails",
"description": "Choose which alerts to send through your existing email connection.",
"openSettings": "Open Email Settings"
},
"migrationDetail": {
@ -6017,9 +6017,18 @@
"more": "+{{hidden}} more"
},
"monitoring": {
"manage": "Manage alerts",
"description": "These settings apply to everyone using this gateway.",
"deprecations": "Model deprecations",
"deprecationsDescription": "Show retirement alerts and send configured email and Slack reminders.",
"adminOnly": "Only gateway admins can change these settings.",
"notifications": "Opt in to Performance Alerts in email or Slack settings to receive a daily summary of new findings. Email summaries go to gateway admins.",
"connections": "Manage email and Slack connections",
"freeNote": "Model deprecation alerts are unlimited. Performance monitoring includes 5 free alerts, then requires Enterprise.",
"enterpriseNote": "Unlimited model deprecation and performance alerts with Enterprise.",
"title": "Performance monitoring",
"loading": "Loading monitoring settings…",
"allowance": "{{used}} of {{limit}} free alerts used.",
"allowance": "{{used}} of {{limit}} free alerts generated.",
"enterprise": "On with Enterprise.",
"off": "Off. Saved alerts remain available.",
"exhausted": "You’ve used all 5 free alerts. New performance alerts are paused.",
@ -6518,7 +6527,8 @@
"dailyReports": "Weekly/Monthly Spend Reports",
"outageAlerts": "Outage Alerts",
"regionOutageAlerts": "Region Outage Alerts",
"modelDeprecationWarnings": "Model Deprecation Warnings"
"modelDeprecationWarnings": "Model Deprecation Warnings",
"performanceAlerts": "Performance Alerts"
},
"enterpriseFeature": "✨ Enterprise Feature",
"saveChanges": "Save Changes",
@ -6625,6 +6635,7 @@
"description": "Select which events should trigger email notifications.",
"descriptions": {
"modelDeprecations": "Email affected key owners and team admins 30 days and 1 day before model deprecation. Uses requests from the last 30 days. Off by default.",
"performanceAlerts": "Email gateway admins a daily summary when there are new performance alerts. Shows up to five alerts with a link to see all.",
"virtualKeyCreated": "An email will be sent to the user when a new virtual key is created with their user ID",
"newUserInvitation": "An email will be sent to the email address of the user when a new user is created",
"generic": "Receive an email notification when {{event}}"

View file

@ -1030,7 +1030,7 @@ export interface paths {
cookie?: never;
};
/**
* Read performance monitoring settings
* Read alert monitoring settings
* @description Read the gateway's opt-out setting and free allowance. Requires a gateway admin or view-only admin.
*/
get: operations["get_monitoring_alerts_monitoring_get"];
@ -1040,7 +1040,7 @@ export interface paths {
options?: never;
head?: never;
/**
* Enable or disable performance monitoring
* Enable or disable alert monitoring
* @description Change monitoring for every worker using this database. Only gateway admins can write. Keeps saved alerts.
*/
patch: operations["update_monitoring_alerts_monitoring_patch"];
@ -27339,6 +27339,12 @@ export interface components {
* @description Whether performance monitoring is enabled. Defaults to true.
*/
enabled: boolean;
/**
* Model Deprecations Enabled
* @description Whether model deprecation alerts are enabled.
* @default true
*/
model_deprecations_enabled: boolean;
};
/** AlertMonitoringUpdate */
AlertMonitoringUpdate: {
@ -27353,7 +27359,7 @@ export interface components {
* @description Enum for alert types and management event types
* @enum {string}
*/
AlertType: "llm_exceptions" | "llm_too_slow" | "llm_requests_hanging" | "budget_alerts" | "spend_reports" | "failed_tracking_spend" | "user_spend_thresholds" | "user_spend_anomalies" | "db_exceptions" | "daily_reports" | "cooldown_deployment" | "new_model_added" | "model_deprecation_warnings" | "outage_alerts" | "region_outage_alerts" | "fallback_reports" | "new_virtual_key_created" | "virtual_key_updated" | "virtual_key_deleted" | "new_team_created" | "team_updated" | "team_deleted" | "new_internal_user_created" | "internal_user_updated" | "internal_user_deleted" | "mcp_tool_description_blocked" | "mcp_pinned_tools_changed";
AlertType: "llm_exceptions" | "llm_too_slow" | "llm_requests_hanging" | "budget_alerts" | "spend_reports" | "failed_tracking_spend" | "user_spend_thresholds" | "user_spend_anomalies" | "db_exceptions" | "daily_reports" | "cooldown_deployment" | "new_model_added" | "model_deprecation_warnings" | "performance_alerts" | "outage_alerts" | "region_outage_alerts" | "fallback_reports" | "new_virtual_key_created" | "virtual_key_updated" | "virtual_key_deleted" | "new_team_created" | "team_updated" | "team_deleted" | "new_internal_user_created" | "internal_user_updated" | "internal_user_deleted" | "mcp_tool_description_blocked" | "mcp_pinned_tools_changed";
/** AlertUpdate */
AlertUpdate: {
/**
@ -33628,7 +33634,7 @@ export interface components {
* EmailEvent
* @enum {string}
*/
EmailEvent: "Model Deprecations" | "Virtual Key Created" | "New User Invitation" | "Virtual Key Rotated" | "Soft Budget Crossed" | "Max Budget Alert";
EmailEvent: "Model Deprecations" | "Performance Alerts" | "Virtual Key Created" | "New User Invitation" | "Virtual Key Rotated" | "Soft Budget Crossed" | "Max Budget Alert";
/** EmailEventSettings */
EmailEventSettings: {
/** Enabled */
@ -40207,6 +40213,14 @@ export interface components {
*/
team_public_model_name?: string | null;
};
/** ModelDeprecationMonitoringUpdate */
ModelDeprecationMonitoringUpdate: {
/**
* Model Deprecations Enabled
* @description Enable or disable model deprecation alerts.
*/
model_deprecations_enabled: boolean;
};
/** ModelDeprecationResponse */
ModelDeprecationResponse: {
/**
@ -55078,7 +55092,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["AlertMonitoringUpdate"];
"application/json": components["schemas"]["AlertMonitoringUpdate"] | components["schemas"]["ModelDeprecationMonitoringUpdate"];
};
};
responses: {

View file

@ -1,7 +1,8 @@
import asyncio
import hashlib
import os
import uuid
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Final
@ -22,6 +23,7 @@ from litellm.models.alert import (
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.alert_repository import AlertRepository
from litellm.repositories.config_repository import ConfigRepository
from tests.integration._support.client import Gateway
from tests.integration._support.database import read_rows, write_rows
@ -41,6 +43,7 @@ def saved_page(content: bytes) -> AlertPage:
@dataclass(frozen=True, slots=True)
class Alerts:
repository: AlertRepository
config: ConfigRepository
resource_id: str
def detection(self, kind: AlertType = "spend", day: int = 0) -> AlertDetection:
@ -75,10 +78,12 @@ async def alerts() -> AsyncIterator[Alerts]:
client: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache()))
await client.connect()
try:
yield Alerts(AlertRepository(client), resource_id)
yield Alerts(AlertRepository(client), ConfigRepository(client, use_writer=True), resource_id)
finally:
try:
write_rows('DELETE FROM "LiteLLM_Alert" WHERE resource_id=%s', (resource_id,))
receipt: Final = "performance_alert_digest:" + hashlib.sha256(f"test:{resource_id}".encode()).hexdigest()
write_rows('DELETE FROM "LiteLLM_Config" WHERE param_name=%s', (receipt,))
finally:
await client.disconnect()
@ -372,3 +377,118 @@ async def test_deleted_resource_preserves_history_and_history_timeout_preserves_
finally:
write_rows('DELETE FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,))
write_rows('DELETE FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,))
@pytest.mark.asyncio
async def test_daily_performance_summary_deduplicates_workers_and_keeps_failed_deliveries(alerts: Alerts) -> None:
from litellm.integrations.performance_alert_notifications import PerformanceAlertNotifier, slack_text
from litellm.models.alert import AlertSummary
destination: Final = f"test:{alerts.resource_id}"
notifier: Final = PerformanceAlertNotifier(alerts.config, alerts.repository)
first: Final = await alerts.repository.record(alerts.detection())
now: Final = first.created_at + timedelta(minutes=1)
delivered: Final[list[str]] = [] # mutable-ok: captures the external delivery boundary
async def send(rows: Sequence[AlertSummary], receipt: str) -> bool:
delivered.append(slack_text(rows, "https://gateway.example"))
return True
async def unavailable(rows: Sequence[AlertSummary], receipt: str) -> bool:
return False
await notifier.deliver(destination, send, now)
assert len(delivered) == 1
for day in range(1, 8):
await alerts.repository.record(alerts.detection(day=day))
restarted: Final = PerformanceAlertNotifier(alerts.config, alerts.repository)
await restarted.deliver(destination, send, now + timedelta(hours=23))
assert len(delivered) == 1
tomorrow: Final = now + timedelta(days=1)
await restarted.deliver(destination, unavailable, tomorrow)
assert len(delivered) == 1
await restarted.deliver(destination, send, tomorrow + timedelta(minutes=3))
assert len(delivered) == 1
retry: Final = tomorrow + timedelta(days=1, minutes=3)
await asyncio.gather(
notifier.deliver(destination, send, retry),
restarted.deliver(destination, send, retry),
)
assert len(delivered) == 2
assert delivered[-1].count("?alert=") == 5
assert f"?alert={first.id}|" not in delivered[-1]
await restarted.deliver(destination, send, retry + timedelta(days=1, minutes=1))
assert len(delivered) == 2
@pytest.mark.asyncio
async def test_first_summary_does_not_replay_old_alerts(alerts: Alerts) -> None:
from litellm.integrations.performance_alert_notifications import PerformanceAlertNotifier
from litellm.models.alert import AlertSummary
saved: Final = await alerts.repository.record(alerts.detection())
delivered: Final[list[tuple[AlertSummary, ...]]] = [] # mutable-ok: captures the external delivery boundary
async def send(rows: Sequence[AlertSummary], receipt: str) -> bool:
delivered.append(tuple(rows))
return True
notifier: Final = PerformanceAlertNotifier(alerts.config, alerts.repository)
now: Final = saved.created_at + timedelta(days=2)
await notifier.deliver(f"test:{alerts.resource_id}", send, now)
assert delivered == []
recent: Final = await alerts.repository.record(alerts.detection(day=1))
await notifier.deliver(f"test:{alerts.resource_id}", send, now + timedelta(minutes=5))
assert tuple(row.id for row in delivered[0]) == (recent.id,)
@pytest.mark.asyncio
@pytest.mark.parametrize("crash", (False, True))
async def test_failed_first_summary_does_not_replay_old_alerts_on_retry(alerts: Alerts, crash: bool) -> None:
from litellm.integrations.performance_alert_notifications import PerformanceAlertNotifier
from litellm.models.alert import AlertSummary
old: Final = await alerts.repository.record(alerts.detection())
recent: Final = await alerts.repository.record(alerts.detection(day=1))
now: Final = recent.created_at + timedelta(days=2)
write_rows('UPDATE "LiteLLM_Alert" SET created_at=%s WHERE id=%s', (now, int(recent.id)))
write_rows('UPDATE "LiteLLM_Alert" SET created_at=%s WHERE id=%s', (now - timedelta(days=2), int(old.id)))
delivered: Final[list[tuple[str, ...]]] = [] # mutable-ok: captures the external delivery boundary
async def unavailable(rows: Sequence[AlertSummary], receipt: str) -> bool:
if crash:
raise RuntimeError("Connection lost after sending")
return False
async def send(rows: Sequence[AlertSummary], receipt: str) -> bool:
delivered.append(tuple(row.id for row in rows))
return True
notifier: Final = PerformanceAlertNotifier(alerts.config, alerts.repository)
if crash:
with pytest.raises(RuntimeError, match="Connection lost after sending"):
await notifier.deliver(f"test:{alerts.resource_id}", unavailable, now)
else:
await notifier.deliver(f"test:{alerts.resource_id}", unavailable, now)
await notifier.deliver(f"test:{alerts.resource_id}", send, now + timedelta(minutes=3))
assert delivered == []
await notifier.deliver(f"test:{alerts.resource_id}", send, now + timedelta(days=1, minutes=3))
assert delivered == [(recent.id,)]
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", (False, True))
async def test_model_deprecation_preference_persists_without_changing_performance(
alerts: Alerts, gateway: Gateway, enabled: bool
) -> None:
previous: Final = gateway.request("GET", "/alerts/monitoring")
assert previous.status_code == 200, previous.text
before: Final = previous.json()
try:
updated: Final = gateway.request("PATCH", "/alerts/monitoring", {"model_deprecations_enabled": enabled})
assert updated.status_code == 200, updated.text
assert updated.json() == {**before, "model_deprecations_enabled": enabled}
refreshed: Final = gateway.request("GET", "/alerts/monitoring")
assert refreshed.json() == updated.json()
finally:
await alerts.config.set_model_deprecations_enabled(before["model_deprecations_enabled"])

View file

@ -2,11 +2,11 @@
import asyncio
from itertools import chain, repeat
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.constants import SLACK_MODEL_DEPRECATION_LOCK_ID
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
@ -53,6 +53,21 @@ async def test_should_skip_when_no_alerting_configured():
assert sent is False
@pytest.mark.asyncio
async def test_gateway_opt_out_stops_deprecation_checks() -> None:
async def disabled() -> bool:
return False
alerting: Final = SlackAlerting(
alerting=["slack"],
alert_types=[AlertType.model_deprecation_warnings],
model_deprecations_enabled=disabled,
)
router: Final = MagicMock()
assert not await alerting.send_model_deprecation_alert(llm_router=router)
router.registry.assert_not_called()
@pytest.mark.asyncio
async def test_should_skip_when_no_deprecations_found(monkeypatch):
monkeypatch.setattr(litellm, "model_cost", {})

View file

@ -4,6 +4,7 @@ import asyncio
from dataclasses import replace
from datetime import date, datetime, timedelta, timezone
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -46,6 +47,9 @@ class MemoryConfig:
def __init__(self):
self.rows = {"general_settings": {"email_settings": {"Model Deprecations": True}}}
async def model_deprecations_enabled(self):
return self.rows.get("model_deprecation_alerts_enabled", True)
async def get_param(self, key):
return ConfigParam(key, self.rows[key]) if key in self.rows else None
@ -63,6 +67,8 @@ class MemoryConfig:
@pytest.fixture()
def sender(monkeypatch):
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@example.com")
instance = module.ModelDeprecationEmailSender(MagicMock())
instance.config = MemoryConfig()
instance._impacts = AsyncMock(
@ -296,3 +302,32 @@ async def test_should_keep_the_email_loop_running_after_a_delivery_error(monkeyp
await module.run_scheduled_model_deprecation_emails()
assert runner.run.await_count == 2
assert all(call.args == (router,) for call in runner.run.await_args_list)
@pytest.mark.asyncio
async def test_gateway_opt_out_stops_owner_usage_scans_and_email(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@example.com")
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock(return_value=MagicMock(param_value=False))
client.db.litellm_dailyuserspend.group_by = AsyncMock()
instance: Final = module.ModelDeprecationEmailSender(client)
assert await instance.run(MagicMock(), now=NOW) == 0
client.db.litellm_dailyuserspend.group_by.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("missing", ("SMTP_HOST", "SMTP_SENDER_EMAIL"))
async def test_missing_email_connection_does_not_scan_owner_usage(
monkeypatch: pytest.MonkeyPatch, missing: str
) -> None:
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@example.com")
monkeypatch.delenv(missing)
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock()
client.db.litellm_dailyuserspend.group_by = AsyncMock()
instance: Final = module.ModelDeprecationEmailSender(client)
assert await instance.run(MagicMock(), now=NOW) == 0
client.writer_db.litellm_config.find_unique.assert_not_awaited()
client.db.litellm_dailyuserspend.group_by.assert_not_awaited()

View file

@ -0,0 +1,213 @@
import smtplib
from datetime import datetime, timezone
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from pydantic import TypeAdapter
from litellm.integrations.performance_alert_notifications import PerformanceAlertNotifier, email_body, slack_text
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.models.alert import AlertSummary, MetricEvidence
from litellm.repositories.alert_repository import AlertRepository
from litellm.repositories.config_repository import ConfigRepository
from litellm.types.integrations.slack_alerting import AlertType
BASE_URL: Final = "https://gateway.example"
WEBHOOK: Final = "https://hooks.slack.example/alerts"
_TEXT: Final = TypeAdapter(dict[str, str])
def summary(index: int) -> AlertSummary:
return AlertSummary(
id=str(index),
type="spend",
resource_type="team",
resource_name=f"Research {index} <team>",
created_at=datetime(2026, 1, 9, tzinfo=timezone.utc),
evidence=MetricEvidence(value=1200, baseline=200, unit="usd", sample_size=2000),
)
def test_daily_messages_include_five_findings_and_escape_resource_names() -> None:
alerts: Final = tuple(summary(index) for index in range(1, 7))
body, text = email_body(alerts, BASE_URL)
slack: Final = slack_text(alerts, BASE_URL)
assert body.count("<li>") == 5
assert slack.count("?alert=") == 5
assert text.count("?alert=") == 5
assert "$1,200.00 vs $200.00" in text
assert "Research 6" not in body + text + slack
assert "Research 1 &lt;team&gt;" in body and "Research 1 &lt;team&gt;" in slack
assert f'<a href="{BASE_URL}/ui/alerts">See all alerts</a>' in body
assert f"<{BASE_URL}/ui/alerts|See all alerts>" in slack
@pytest.mark.parametrize(
("kind", "metric", "expected"),
(
("reliability", MetricEvidence(value=0.25, baseline=0.02, unit="ratio", sample_size=2000), "25.0% vs 2.0%"),
(
"latency",
MetricEvidence(value=4000, baseline=1500, unit="milliseconds", sample_size=2000),
"4,000 ms vs 1,500 ms",
),
(
"traffic",
MetricEvidence(value=12000, baseline=2000, unit="requests", sample_size=2000),
"12,000 requests vs 2,000 requests",
),
),
)
def test_daily_messages_preserve_metric_units(
kind: Literal["reliability", "latency", "traffic"], metric: MetricEvidence, expected: str
) -> None:
alert: Final = summary(1).model_copy(update={"type": kind, "evidence": metric})
body, text = email_body((alert,), BASE_URL)
assert expected in body and expected in text
assert expected in slack_text((alert,), BASE_URL)
@pytest.mark.asyncio
async def test_connected_channels_do_not_subscribe_to_performance_summaries(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PROXY_BASE_URL", BASE_URL)
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@example.com")
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
client.writer_db.tx = MagicMock()
slack: Final = SlackAlerting(alerting=["slack"], default_webhook_url=WEBHOOK)
notifier: Final = PerformanceAlertNotifier(ConfigRepository(client, use_writer=True), AlertRepository(client))
await notifier.run(slack)
assert slack.performance_alert_webhooks() == ()
client.writer_db.tx.assert_not_called()
@pytest.mark.asyncio
async def test_slack_summary_reuses_the_configured_connection_and_reports_delivery() -> None:
posted: Final = MagicMock(return_value=httpx.Response(200, text="ok"))
handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(posted))
try:
slack: Final = SlackAlerting(
alerting=["slack"],
alert_types=[AlertType.performance_alerts],
default_webhook_url=WEBHOOK,
async_http_handler=handler,
)
assert slack.performance_alert_webhooks() == (WEBHOOK,)
message: Final = slack_text((summary(1),), BASE_URL)
assert await slack.send_performance_alert_summary(WEBHOOK, message)
request: Final = posted.call_args.args[0]
assert str(request.url) == WEBHOOK
assert _TEXT.validate_json(request.content) == {"text": message}
finally:
await handler.close()
@pytest.mark.asyncio
async def test_opted_in_summary_reaches_slack_from_the_notification_job(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PROXY_BASE_URL", BASE_URL)
monkeypatch.delenv("SMTP_HOST", raising=False)
alert: Final = summary(1)
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
client.writer_db.query_raw = AsyncMock(return_value=[{"param_name": "receipt"}])
client.writer_db.tx.return_value.__aenter__.return_value.query_raw = AsyncMock(
side_effect=((), ({"id": 1},), (), (alert.model_dump(mode="json"),))
)
posted: Final = MagicMock(return_value=httpx.Response(200, text="ok"))
handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(posted))
try:
slack: Final = SlackAlerting(
alerting=["slack"],
alert_types=[AlertType.performance_alerts],
default_webhook_url=WEBHOOK,
async_http_handler=handler,
)
notifier: Final = PerformanceAlertNotifier(ConfigRepository(client, use_writer=True), AlertRepository(client))
await notifier.run(slack, now=alert.created_at)
posted.assert_called_once()
assert _TEXT.validate_json(posted.call_args.args[0].content) == {"text": slack_text((alert,), BASE_URL)}
finally:
await handler.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", (httpx.ReadTimeout("Webhook timed out"), httpx.Response(503)))
async def test_unavailable_webhook_does_not_block_other_destinations(
monkeypatch: pytest.MonkeyPatch, failure: Exception | httpx.Response
) -> None:
monkeypatch.setenv("PROXY_BASE_URL", BASE_URL)
monkeypatch.delenv("SMTP_HOST", raising=False)
alert: Final = summary(1)
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
client.writer_db.query_raw = AsyncMock(return_value=[{"param_name": "receipt"}])
client.writer_db.tx.return_value.__aenter__.return_value.query_raw = AsyncMock(
side_effect=((), ({"id": 1},), (), (alert.model_dump(mode="json"),)) * 2
)
posted: Final = MagicMock(side_effect=(failure, httpx.Response(200)))
handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(posted))
try:
slack: Final = SlackAlerting(
alerting=["slack"],
alert_types=[AlertType.performance_alerts],
alert_to_webhook_url={AlertType.performance_alerts: [WEBHOOK, WEBHOOK + "/second", WEBHOOK]},
async_http_handler=handler,
)
notifier: Final = PerformanceAlertNotifier(ConfigRepository(client, use_writer=True), AlertRepository(client))
await notifier.run(slack, now=alert.created_at)
assert tuple(str(call.args[0].url) for call in posted.call_args_list) == (WEBHOOK, WEBHOOK + "/second")
assert _TEXT.validate_json(posted.call_args.args[0].content) == {"text": slack_text((alert,), BASE_URL)}
finally:
await handler.close()
@pytest.mark.asyncio
async def test_opted_in_email_reaches_recipients_across_pages(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PROXY_BASE_URL", BASE_URL)
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "25")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@example.com")
monkeypatch.setenv("SMTP_TLS", "False")
monkeypatch.setenv("SMTP_USE_SSL", "False")
monkeypatch.delenv("SMTP_USERNAME", raising=False)
monkeypatch.delenv("SMTP_PASSWORD", raising=False)
smtp: Final = MagicMock()
monkeypatch.setattr(smtplib, "SMTP", MagicMock(return_value=smtp))
alert: Final = summary(1)
client: Final = MagicMock()
client.writer_db.litellm_config.find_unique = AsyncMock(
side_effect=(MagicMock(param_value={"email_settings": {"Performance Alerts": True}}), None, None)
)
client.writer_db.query_raw = AsyncMock(return_value=[{"param_name": "receipt"}])
client.writer_db.tx.return_value.__aenter__.return_value.query_raw = AsyncMock(
side_effect=(
(),
({"user_id": "a", "email": "first@example.com"},),
(),
({"id": 1},),
(),
(alert.model_dump(mode="json"),),
(),
({"user_id": "b", "email": "second@example.com"},),
(),
({"id": 1},),
(),
(alert.model_dump(mode="json"),),
(),
(),
)
)
notifier: Final = PerformanceAlertNotifier(ConfigRepository(client, use_writer=True), AlertRepository(client))
await notifier.run(None, now=alert.created_at)
sends: Final = smtp.__enter__.return_value.send_message.call_args_list
assert tuple(call.kwargs["to_addrs"] for call in sends) == ("first@example.com", "second@example.com")
for call in sends:
message: Final = call.kwargs["msg"]
assert message["Subject"] == "New performance alerts"
assert f"{BASE_URL}/ui/alerts?alert=1" in message.get_payload(0).get_payload(decode=True).decode()
assert message["Message-ID"]
assert sends[0].kwargs["msg"]["Message-ID"] != sends[1].kwargs["msg"]["Message-ID"]

View file

@ -55,6 +55,7 @@ def app(table: MagicMock, history_query: AsyncMock) -> FastAPI:
app.include_router(router)
prisma: Final = MagicMock()
prisma.writer_db.litellm_alert = table
prisma.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
prisma.writer_db.tx.return_value.__aenter__.return_value.query_raw = history_query
app.dependency_overrides[get_database] = lambda: Database(prisma, native_database())
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
@ -123,8 +124,38 @@ def test_monitoring_only_accepts_a_boolean_opt_out(
history_query.assert_not_awaited()
@pytest.mark.parametrize("enabled", (False, True))
def test_deprecation_preference_updates_without_changing_performance(app: FastAPI, enabled: bool) -> None:
prisma: Final = MagicMock()
prisma.writer_db.litellm_config.find_unique = AsyncMock(return_value=MagicMock(param_value=enabled))
prisma.writer_db.litellm_config.upsert = AsyncMock()
transaction: Final = prisma.writer_db.tx.return_value.__aenter__.return_value
transaction.query_raw = AsyncMock(side_effect=((), ({"enabled": False, "alerts_used": 3},)))
transaction.execute_raw = AsyncMock()
app.dependency_overrides[get_alert_monitoring_repository] = lambda: PerformanceAlertRepository(prisma)
with TestClient(app) as client:
response: Final = client.patch("/alerts/monitoring", json={"model_deprecations_enabled": enabled})
assert response.status_code == 200, response.text
assert response.json() == {
"enabled": False,
"model_deprecations_enabled": enabled,
"alerts_used": 3,
"alert_limit": 5,
"disabled_by_config": False,
}
prisma.writer_db.litellm_config.upsert.assert_awaited_once_with(
where={"param_name": "model_deprecation_alerts_enabled"},
data={
"create": {"param_name": "model_deprecation_alerts_enabled", "param_value": str(enabled).lower()},
"update": {"param_value": str(enabled).lower()},
},
)
transaction.execute_raw.assert_not_awaited()
def test_config_opt_out_cannot_be_enabled_from_the_api(app: FastAPI) -> None:
prisma: Final = MagicMock()
prisma.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
transaction: Final = prisma.writer_db.tx.return_value.__aenter__.return_value
transaction.query_raw = AsyncMock(return_value=[{"enabled": True, "alerts_used": 0}])
transaction.execute_raw = AsyncMock()
@ -134,7 +165,13 @@ def test_config_opt_out_cannot_be_enabled_from_the_api(app: FastAPI) -> None:
with TestClient(app) as client:
status: Final = client.get("/alerts/monitoring")
response: Final = client.patch("/alerts/monitoring", json={"enabled": True})
assert status.json() == {"enabled": False, "alerts_used": 0, "alert_limit": 5, "disabled_by_config": True}
assert status.json() == {
"enabled": False,
"alerts_used": 0,
"alert_limit": 5,
"disabled_by_config": True,
"model_deprecations_enabled": True,
}
assert response.status_code == 409, response.text
transaction.execute_raw.assert_not_awaited()

View file

@ -49,6 +49,7 @@ def _repository(
transaction: Final = MagicMock(query_raw=query, execute_raw=execute)
context: Final = MagicMock(__aenter__=AsyncMock(return_value=transaction), __aexit__=AsyncMock(return_value=False))
client: Final = MagicMock(writer_db=MagicMock(tx=MagicMock(return_value=context)))
client.writer_db.litellm_config.find_unique = AsyncMock(return_value=None)
return PerformanceAlertRepository(client, enterprise=enterprise), query, execute
@ -191,7 +192,12 @@ async def test_first_read_initializes_the_allowance_once(enterprise: bool) -> No
@pytest.mark.parametrize("enabled", (False, True))
async def test_admin_toggle_persists_the_requested_value(enabled: bool) -> None:
repository, query, execute = _repository(())
query.side_effect = ((), ({"enabled": not enabled, "alerts_used": 2},), ({"enabled": enabled, "alerts_used": 2},))
query.side_effect = (
(),
({"enabled": not enabled, "alerts_used": 2},),
(),
({"enabled": enabled, "alerts_used": 2},),
)
result: Final = await repository.set_enabled(enabled)
assert result.enabled is enabled
assert result.active is enabled