diff --git a/litellm/models/alert.py b/litellm/models/alert.py
index c9a26c6aff9..f96e042ff6b 100644
--- a/litellm/models/alert.py
+++ b/litellm/models/alert.py
@@ -1,10 +1,12 @@
-from datetime import date, datetime, timezone
+from datetime import date as Date
+from datetime import datetime, timezone
from typing import Annotated, Final, Literal, Protocol, TypeAlias
from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Self
AlertType: TypeAlias = Literal["spend", "traffic", "latency", "reliability", "model_deprecation"]
+AlertFilter: TypeAlias = AlertType | Literal["performance"]
AlertStatus: TypeAlias = Literal["open", "resolved", "dismissed"]
AlertResourceType: TypeAlias = Literal["gateway", "key", "team", "user", "model"]
MAX_ALERT_ID: Final = 2**63 - 1
@@ -33,7 +35,7 @@ class MetricEvidence(BaseModel):
class DeprecationEvidence(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
- deprecation_date: date = Field(description="Date the affected model retires.")
+ deprecation_date: Date = Field(description="Date the affected model retires.")
replacement_model: str | None = Field(
default=None, min_length=1, max_length=1024, description="Suggested replacement."
)
@@ -106,6 +108,22 @@ class Alert(AlertDetection):
updated_by: str | None = Field(default=None, max_length=1024, description="User who last changed the status.")
+class AlertHistoryPoint(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ date: Date = Field(description="UTC day represented by this value.")
+ value: float = Field(allow_inf_nan=False, description="Recorded daily value in the alert metric's unit.")
+
+
+class AlertView(Alert):
+ resource_name: str | None = Field(default=None, max_length=1024, description="Current resource display name.")
+ history: tuple[AlertHistoryPoint, ...] | None = Field(
+ default=None,
+ max_length=8,
+ description="Current Usage values for the seven baseline days and observed day. Null if unavailable.",
+ )
+
+
class AlertUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -121,10 +139,21 @@ class AlertPage(BaseModel):
)
+class AlertViewPage(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ data: tuple[AlertView, ...] = Field(description="Alerts ordered newest first, with current Usage history.")
+ next_cursor: AlertID | None = Field(default=None, description="Cursor for the next page, or null at the end.")
+
+
class AlertStore(Protocol):
async def record(self, detection: AlertDetection) -> Alert: ...
- async def list(self, status: AlertStatus, limit: int, cursor: int | None) -> AlertPage: ...
+ async def list(
+ self, status: AlertStatus, limit: int, cursor: int | None, alert_type: AlertFilter | None = None
+ ) -> AlertPage: ...
+
+ async def enrich(self, alerts: tuple[Alert, ...]) -> tuple[AlertView, ...]: ...
async def get(self, alert_id: int) -> Alert | None: ...
diff --git a/litellm/proxy/alerting_endpoints/alerts.py b/litellm/proxy/alerting_endpoints/alerts.py
index fa586ca7a25..3adcab90ec8 100644
--- a/litellm/proxy/alerting_endpoints/alerts.py
+++ b/litellm/proxy/alerting_endpoints/alerts.py
@@ -2,7 +2,16 @@ from typing import Annotated, Final, TypeAlias
from fastapi import APIRouter, Depends, HTTPException, Path, Query
-from litellm.models.alert import Alert, AlertID, AlertPage, AlertStatus, AlertStore, AlertUpdate
+from litellm.models.alert import (
+ Alert,
+ AlertFilter,
+ AlertID,
+ AlertStatus,
+ AlertStore,
+ AlertUpdate,
+ AlertView,
+ AlertViewPage,
+)
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 AlertRepositoryDep
@@ -29,24 +38,29 @@ def _found(alert: Alert | None) -> Alert:
return alert
-@router.get("/alerts", response_model=AlertPage, summary="List saved alerts")
+@router.get("/alerts", response_model=AlertViewPage, summary="List saved alerts")
async def list_alerts(
user: Auth,
repository: AlertRepositoryDep,
status: Annotated[AlertStatus, Query(description="Status to list.")] = "open",
limit: Annotated[int, Query(ge=1, le=100, description="Maximum alerts per page.")] = 25,
cursor: Annotated[AlertID | None, Query(description="next_cursor from the preceding page.")] = None,
-) -> AlertPage:
- """Read saved alerts, newest first. Gateway admins and view-only admins can read. No usage queries run."""
+ alert_type: Annotated[
+ AlertFilter | None, Query(alias="type", description="Alert category, or all performance alerts.")
+ ] = None,
+) -> AlertViewPage:
+ """Read saved alerts with current resource names and eight days of Usage history. Requires a gateway admin."""
store: Final = _authorized_store(user, repository, write=False)
- return await store.list(status, limit, int(cursor) if cursor is not None else None)
+ page: Final = await store.list(status, limit, int(cursor) if cursor is not None else None, alert_type)
+ return AlertViewPage(data=await store.enrich(page.data), next_cursor=page.next_cursor)
-@router.get("/alerts/{alert_id}", response_model=Alert, summary="Read an alert")
-async def get_alert(alert_id: AlertPath, user: Auth, repository: AlertRepositoryDep) -> Alert:
+@router.get("/alerts/{alert_id}", response_model=AlertView, summary="Read an alert")
+async def get_alert(alert_id: AlertPath, user: Auth, repository: AlertRepositoryDep) -> AlertView:
"""Return the saved evidence and status. Requires a gateway admin or view-only admin."""
store: Final = _authorized_store(user, repository, write=False)
- return _found(await store.get(int(alert_id)))
+ alert: Final = _found(await store.get(int(alert_id)))
+ return (await store.enrich((alert,)))[0]
@router.patch("/alerts/{alert_id}", response_model=Alert, summary="Change an alert's status")
diff --git a/litellm/repositories/alert_repository.py b/litellm/repositories/alert_repository.py
index 299b1a67bdc..82048d53d83 100644
--- a/litellm/repositories/alert_repository.py
+++ b/litellm/repositories/alert_repository.py
@@ -1,6 +1,9 @@
import hashlib
import json
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
+from contextlib import AbstractAsyncContextManager
+from datetime import date as Date
+from datetime import timedelta
from typing import (
TYPE_CHECKING,
Final,
@@ -8,13 +11,103 @@ from typing import (
cast, # noqa: TID251 # Prisma exposes generated table actions dynamically
)
-from litellm.models.alert import Alert, AlertDetection, AlertPage, AlertStatus
+from pydantic import BaseModel, ConfigDict, TypeAdapter
+from typing_extensions import LiteralString
+
+from litellm._logging import verbose_proxy_logger
+from litellm.models.alert import (
+ Alert,
+ AlertDetection,
+ AlertFilter,
+ AlertHistoryPoint,
+ AlertPage,
+ AlertResourceType,
+ AlertStatus,
+ AlertView,
+)
from litellm.repositories.prisma_protocols import PrismaRecord, TableActions
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
+class _HistoryTransaction(Protocol):
+ async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ...
+
+
+class _HistoryDatabase(Protocol):
+ def tx(self, *, timeout: timedelta) -> AbstractAsyncContextManager[_HistoryTransaction]: ...
+
+
+class _HistoryRow(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ id: str
+ resource_name: str | None
+ date: Date | None
+ spend: float
+ requests: int
+ failed: int
+ response_ms: int
+ timed: int
+
+ def point(self, kind: str) -> AlertHistoryPoint | None:
+ if self.date is None or (kind == "latency" and not self.timed) or (kind == "reliability" and not self.requests):
+ return None
+ value: Final = {
+ "spend": self.spend,
+ "traffic": float(self.requests),
+ "latency": self.response_ms / self.timed if self.timed else 0,
+ "reliability": min(1.0, self.failed / self.requests) if self.requests else 0,
+ }[kind]
+ return AlertHistoryPoint(date=self.date, value=value)
+
+
+_HISTORY_ROWS: Final = TypeAdapter(tuple[_HistoryRow, ...])
+_HISTORY_SOURCES: Final[
+ Mapping[AlertResourceType, tuple[LiteralString, LiteralString, LiteralString, LiteralString, LiteralString]]
+] = {
+ "gateway": ('"LiteLLM_DailyModelUsage"', "'gateway'", "request_count", "", "'Gateway'"),
+ "model": ('"LiteLLM_DailyModelUsage"', "d.model_group", "request_count", "", "r.resource_id"),
+ "team": (
+ '"LiteLLM_DailyTeamSpend"',
+ "d.team_id",
+ "api_requests",
+ 'LEFT JOIN "LiteLLM_TeamTable" m ON m.team_id = r.resource_id',
+ "m.team_alias",
+ ),
+ "user": (
+ '"LiteLLM_DailyUserSpend"',
+ "d.user_id",
+ "api_requests",
+ 'LEFT JOIN "LiteLLM_UserTable" m ON m.user_id = r.resource_id',
+ "m.user_alias",
+ ),
+ "key": (
+ '"LiteLLM_DailyUserSpend"',
+ "d.api_key",
+ "api_requests",
+ 'LEFT JOIN "LiteLLM_VerificationToken" m ON m.token = r.resource_id',
+ "m.key_alias",
+ ),
+}
+
+
+def _history_sql(scope: AlertResourceType) -> LiteralString:
+ table, resource, requests, metadata, name = _HISTORY_SOURCES[scope]
+ return f"""SELECT r.id, LEFT({name}, 1024) AS resource_name, d.date,
+ COALESCE(SUM(d.spend), 0)::float8 AS spend,
+ COALESCE(SUM(d.{requests}), 0)::bigint AS requests,
+ COALESCE(SUM(d.failed_requests), 0)::bigint AS failed,
+ COALESCE(SUM(d.total_response_time_ms), 0)::bigint AS response_ms,
+ COALESCE(SUM(d.timed_requests), 0)::bigint AS timed
+ FROM requested r
+ LEFT JOIN {table} d ON {resource} = r.resource_id AND d.date BETWEEN r.start AND r.day
+ {metadata}
+ WHERE r.resource_type = '{scope}'
+ GROUP BY r.id, LEFT({name}, 1024), d.date"""
+
+
class AlertTransaction(Protocol):
async def execute_raw(self, query: str, *args: object) -> int: ...
@@ -52,6 +145,9 @@ def _alert(row: PrismaRecord) -> Alert:
class AlertRepository:
def __init__(self, prisma_client: "PrismaClient") -> None:
+ self._database: Final = cast( # cast-ok: Prisma transaction exposes bounded raw query methods
+ _HistoryDatabase, prisma_client.writer_db
+ )
self._table = cast( # cast-ok: the generated Prisma client delegates typed table actions dynamically
TableActions[PrismaRecord], prisma_client.writer_db.litellm_alert
)
@@ -76,10 +172,17 @@ class AlertRepository:
raise
return _alert(existing)
- async def list(self, status: AlertStatus, limit: int, cursor: int | None) -> AlertPage:
+ async def list(
+ self, status: AlertStatus, limit: int, cursor: int | None, alert_type: AlertFilter | None = None
+ ) -> AlertPage:
size: Final = max(1, min(limit, 100))
+ category: Final = {"not": "model_deprecation"} if alert_type == "performance" else alert_type
rows: Final = await self._table.find_many(
- where={"status": status, **({"id": {"lt": cursor}} if cursor is not None else {})},
+ where={
+ "status": status,
+ **({"id": {"lt": cursor}} if cursor is not None else {}),
+ **({"type": category} if category is not None else {}),
+ },
order={"id": "desc"},
take=size + 1,
)
@@ -95,3 +198,49 @@ class AlertRepository:
async def set_status(self, alert_id: int, status: AlertStatus, actor: str) -> Alert | None:
row: Final = await self._table.update(where={"id": alert_id}, data={"status": status, "updated_by": actor})
return None if row is None else _alert(row)
+
+ async def enrich(self, alerts: tuple[Alert, ...]) -> tuple[AlertView, ...]:
+ from prisma.errors import PrismaError
+
+ requested: Final = tuple(
+ {
+ "id": alert.id,
+ "resource_id": alert.resource_id,
+ "resource_type": alert.resource_type,
+ "day": alert.window_start.date().isoformat(),
+ "start": (alert.window_start.date() - timedelta(days=7)).isoformat(),
+ }
+ for alert in alerts
+ if alert.type != "model_deprecation"
+ )
+ if not requested:
+ return tuple(AlertView.model_validate(alert.model_dump()) for alert in alerts)
+ scopes: Final = frozenset[AlertResourceType](alert.resource_type for alert in alerts)
+ histories: Final = " UNION ALL ".join(_history_sql(scope) for scope in scopes)
+ query: Final = f"""WITH requested AS (
+ SELECT * FROM jsonb_to_recordset($1::jsonb)
+ AS r(id text, resource_id text, resource_type text, day text, start text)
+ ) {histories} ORDER BY id, date"""
+ try:
+ async with self._database.tx(timeout=timedelta(seconds=3)) as transaction:
+ await transaction.query_raw(
+ "SELECT set_config('statement_timeout', '2s', true), set_config('work_mem', '1MB', true), "
+ "set_config('max_parallel_workers_per_gather', '0', true)"
+ )
+ rows: Final = _HISTORY_ROWS.validate_python(await transaction.query_raw(query, json.dumps(requested)))
+ return tuple(_view(alert, rows) for alert in alerts)
+ except (PrismaError, TimeoutError, ValueError) as error:
+ verbose_proxy_logger.warning("Alert Usage history unavailable (%s)", type(error).__name__)
+ return tuple(AlertView.model_validate(alert.model_dump()) for alert in alerts)
+
+
+def _view(alert: Alert, rows: tuple[_HistoryRow, ...]) -> AlertView:
+ history: Final = tuple(row for row in rows if row.id == alert.id)
+ points: Final = tuple(point for row in history if (point := row.point(alert.type)) is not None)
+ return AlertView.model_validate(
+ {
+ **alert.model_dump(),
+ "resource_name": history[0].resource_name if history else None,
+ "history": points,
+ }
+ )
diff --git a/tests/integration/database/test_alerts.py b/tests/integration/database/test_alerts.py
index b3054edba02..dc62886b75b 100644
--- a/tests/integration/database/test_alerts.py
+++ b/tests/integration/database/test_alerts.py
@@ -8,8 +8,17 @@ from typing import Final
import pytest
import pytest_asyncio
+from psycopg import sql
-from litellm.models.alert import Alert, AlertDetection, AlertPage, AlertType
+from litellm.models.alert import (
+ Alert,
+ AlertDetection,
+ AlertPage,
+ AlertResourceType,
+ AlertType,
+ AlertView,
+ AlertViewPage,
+)
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
@@ -17,6 +26,18 @@ from tests.integration._support.client import Gateway
from tests.integration._support.database import read_rows, write_rows
+def saved_alert(content: bytes) -> Alert:
+ view: Final = AlertView.model_validate_json(content)
+ return Alert.model_validate(view.model_dump(exclude={"resource_name", "history"}))
+
+
+def saved_page(content: bytes) -> AlertPage:
+ page: Final = AlertViewPage.model_validate_json(content)
+ return AlertPage(
+ data=tuple(saved_alert(alert.model_dump_json().encode()) for alert in page.data), next_cursor=page.next_cursor
+ )
+
+
@dataclass(frozen=True, slots=True)
class Alerts:
repository: AlertRepository
@@ -71,13 +92,13 @@ async def test_each_alert_type_round_trips_through_api_and_status_changes(
saved: Final = await alerts.repository.record(detection)
response: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert response.status_code == 200, response.text
- assert Alert.model_validate_json(response.content) == saved
+ assert saved_alert(response.content) == saved
assert saved.status == "open"
assert saved.model_dump(include=set(AlertDetection.model_fields)) == detection.model_dump()
for status in ("resolved", "dismissed", "open"):
update: Final = gateway.request("PATCH", f"/alerts/{saved.id}", {"status": status})
assert update.status_code == 200, update.text
- changed: Final = Alert.model_validate_json(update.content)
+ changed: Final = saved_alert(update.content)
assert changed.status == status
assert changed.updated_by
assert changed.evidence == saved.evidence
@@ -100,13 +121,13 @@ async def test_gateway_alert_round_trips_without_colliding_with_resource_alerts(
assert scoped.id != saved.id
detail: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert detail.status_code == 200, detail.text
- assert Alert.model_validate_json(detail.content) == saved
+ assert saved_alert(detail.content) == saved
listed: Final = gateway.request("GET", "/alerts", params={"limit": "2"})
assert listed.status_code == 200, listed.text
- assert AlertPage.model_validate_json(listed.content).data == (scoped, saved)
+ assert saved_page(listed.content).data == (scoped, saved)
update: Final = gateway.request("PATCH", f"/alerts/{saved.id}", {"status": "dismissed"})
assert update.status_code == 200, update.text
- dismissed: Final = Alert.model_validate_json(update.content)
+ dismissed: Final = saved_alert(update.content)
assert dismissed.status == "dismissed"
assert await alerts.repository.record(detection) == dismissed
assert await alerts.repository.get(int(scoped.id)) == scoped
@@ -143,19 +164,19 @@ async def test_keyset_pages_survive_status_changes_and_do_not_include_newer_aler
newest: Final = await alerts.repository.record(alerts.detection(day=2))
first: Final = gateway.request("GET", "/alerts", params={"limit": "2"})
assert first.status_code == 200, first.text
- page: Final = AlertPage.model_validate_json(first.content)
+ page: Final = saved_page(first.content)
assert tuple(alert.id for alert in page.data) == (newest.id, middle.id)
assert page.next_cursor == middle.id
await alerts.repository.set_status(int(middle.id), "resolved", "reviewer")
late: Final = await alerts.repository.record(alerts.detection(day=3))
second: Final = gateway.request("GET", "/alerts", params={"limit": "2", "cursor": middle.id})
assert second.status_code == 200, second.text
- remainder: Final = AlertPage.model_validate_json(second.content)
+ remainder: Final = saved_page(second.content)
assert oldest.id in tuple(alert.id for alert in remainder.data)
assert not {newest.id, middle.id, late.id}.intersection(alert.id for alert in remainder.data)
resolved: Final = gateway.request("GET", "/alerts", params={"status": "resolved", "limit": "1"})
assert resolved.status_code == 200, resolved.text
- assert AlertPage.model_validate_json(resolved.content).data[0].id == middle.id
+ assert saved_page(resolved.content).data[0].id == middle.id
@pytest.mark.asyncio
@@ -212,3 +233,140 @@ async def test_windows_round_trip_at_database_precision_and_timezone(alerts: Ale
}
)
assert await alerts.repository.record(shifted) == saved
+
+
+@pytest.mark.parametrize("scope", ("model", "key", "user", "team", "gateway"))
+@pytest.mark.asyncio
+async def test_daily_history_matches_scope_and_weighted_metrics(
+ alerts: Alerts, gateway: Gateway, scope: AlertResourceType
+) -> None:
+ resource: Final = {"key": uuid.uuid4().hex * 2, "gateway": "gateway"}.get(scope, alerts.resource_id)
+ model_source: Final = scope in ("model", "gateway")
+ table, column = {
+ "model": ("LiteLLM_DailyModelUsage", "model_group"),
+ "gateway": ("LiteLLM_DailyModelUsage", "model_group"),
+ "team": ("LiteLLM_DailyTeamSpend", "team_id"),
+ "user": ("LiteLLM_DailyUserSpend", "user_id"),
+ "key": ("LiteLLM_DailyUserSpend", "api_key"),
+ }[scope]
+ marker: Final = alerts.resource_id
+ request_column: Final = "request_count" if model_source else "api_requests"
+ insert: Final = sql.SQL("""INSERT INTO {} ({}, date, model, {}, {}, spend, {}, failed_requests,
+ total_response_time_ms, timed_requests, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())""").format(
+ sql.Identifier(table),
+ sql.Identifier(column),
+ sql.Identifier("custom_llm_provider" if model_source else "id"),
+ sql.Identifier("task_type" if model_source else ("user_id" if scope == "key" else "api_key")),
+ sql.Identifier(request_column),
+ )
+ try:
+ for day in ("2025-12-24", "2025-12-25", "2026-01-01", "2026-01-02"):
+ for deployment, count in (("a", 2), ("b", 8)):
+ write_rows(
+ insert,
+ (
+ resource,
+ day,
+ marker + deployment,
+ "example" if model_source else uuid.uuid4().hex,
+ "chat" if model_source else "",
+ float(count),
+ count,
+ 1,
+ count * count * 100,
+ count,
+ ),
+ )
+ for kind, expected in (("spend", 10), ("traffic", 10), ("latency", 680), ("reliability", 0.2)):
+ detection: Final = AlertDetection.model_validate(
+ {
+ **alerts.detection(kind).model_dump(),
+ "resource_type": scope,
+ "resource_id": resource,
+ }
+ )
+ saved: Final = await alerts.repository.record(detection)
+ response: Final = gateway.request("GET", f"/alerts/{saved.id}")
+ assert response.status_code == 200, response.text
+ view: Final = AlertView.model_validate_json(response.content)
+ assert view.history is not None
+ assert tuple(point.date.isoformat() for point in view.history) == ("2025-12-25", "2026-01-01")
+ assert tuple(point.value for point in view.history) == pytest.approx((expected, expected))
+ assert view.evidence == saved.evidence
+ assert view.resource_name == (
+ resource if model_source and scope != "gateway" else "Gateway" if scope == "gateway" else None
+ )
+ listed: Final = gateway.request("GET", "/alerts", params={"type": kind, "limit": "1"})
+ assert listed.status_code == 200, listed.text
+ assert AlertViewPage.model_validate_json(listed.content).data[0] == view
+ finally:
+ write_rows(
+ sql.SQL("DELETE FROM {} WHERE model IN (%s, %s)").format(sql.Identifier(table)),
+ (marker + "a", marker + "b"),
+ )
+ write_rows('DELETE FROM "LiteLLM_Alert" WHERE resource_id=%s', (resource,))
+
+
+@pytest.mark.asyncio
+async def test_type_filter_applies_before_cursor_limit(alerts: Alerts, gateway: Gateway) -> None:
+ oldest: Final = await alerts.repository.record(alerts.detection("traffic"))
+ await alerts.repository.record(alerts.detection("spend"))
+ newest: Final = await alerts.repository.record(alerts.detection("traffic", day=1))
+ await alerts.repository.record(alerts.detection("model_deprecation"))
+ page: Final = gateway.request("GET", "/alerts", params={"type": "traffic", "limit": "1"})
+ assert page.status_code == 200, page.text
+ first: Final = AlertViewPage.model_validate_json(page.content)
+ assert first.data[0].id == newest.id
+ assert first.next_cursor == newest.id
+ following: Final = gateway.request("GET", "/alerts", params={"type": "traffic", "limit": "1", "cursor": newest.id})
+ assert following.status_code == 200, following.text
+ assert AlertViewPage.model_validate_json(following.content).data[0].id == oldest.id
+ performance: Final = gateway.request("GET", "/alerts", params={"type": "performance"})
+ assert performance.status_code == 200, performance.text
+ assert all(
+ alert.type != "model_deprecation" for alert in AlertViewPage.model_validate_json(performance.content).data
+ )
+
+
+@pytest.mark.asyncio
+async def test_deleted_resource_preserves_history_and_history_timeout_preserves_alert(
+ alerts: Alerts, gateway: Gateway
+) -> None:
+ import psycopg
+
+ team: Final = alerts.resource_id
+ write_rows('INSERT INTO "LiteLLM_TeamTable" (team_id, team_alias) VALUES (%s, %s)', (team, "Example team"))
+ write_rows(
+ """INSERT INTO "LiteLLM_DailyTeamSpend" (id, team_id, api_key, date, spend, updated_at)
+ VALUES (%s, %s, '', '2026-01-01', 20, NOW())""",
+ (team, team),
+ )
+ try:
+ saved: Final = await alerts.repository.record(
+ AlertDetection.model_validate(
+ {
+ **alerts.detection().model_dump(),
+ "resource_type": "team",
+ }
+ )
+ )
+ first: Final = gateway.request("GET", f"/alerts/{saved.id}")
+ assert first.status_code == 200, first.text
+ assert AlertView.model_validate_json(first.content).resource_name == "Example team"
+ write_rows('DELETE FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,))
+ deleted: Final = gateway.request("GET", f"/alerts/{saved.id}")
+ assert deleted.status_code == 200, deleted.text
+ view: Final = AlertView.model_validate_json(deleted.content)
+ assert view.resource_name is None
+ assert view.history and view.history[0].value == 20
+ with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
+ connection.execute('LOCK TABLE "LiteLLM_DailyTeamSpend" IN ACCESS EXCLUSIVE MODE')
+ unavailable: Final = gateway.request("GET", f"/alerts/{saved.id}")
+ assert unavailable.status_code == 200, unavailable.text
+ fallback: Final = AlertView.model_validate_json(unavailable.content)
+ assert fallback.history is None
+ assert fallback.evidence == saved.evidence
+ finally:
+ write_rows('DELETE FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,))
+ write_rows('DELETE FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,))
diff --git a/tests/unit/proxy/alerting_endpoints/test_alerts.py b/tests/unit/proxy/alerting_endpoints/test_alerts.py
index 15dfad975fc..bb651ef3807 100644
--- a/tests/unit/proxy/alerting_endpoints/test_alerts.py
+++ b/tests/unit/proxy/alerting_endpoints/test_alerts.py
@@ -43,11 +43,17 @@ def table() -> MagicMock:
@pytest.fixture
-def app(table: MagicMock) -> FastAPI:
+def history_query() -> AsyncMock:
+ return AsyncMock(return_value=[])
+
+
+@pytest.fixture
+def app(table: MagicMock, history_query: AsyncMock) -> FastAPI:
app: Final = FastAPI()
app.include_router(router)
prisma: Final = MagicMock()
prisma.writer_db.litellm_alert = table
+ 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(
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
@@ -79,7 +85,8 @@ def test_permissions_precede_database_access(
@pytest.mark.parametrize(
- "query", ("limit=0", "limit=101", "cursor=0", "cursor=9223372036854775808", "cursor=oops", "status=invalid")
+ "query",
+ ("limit=0", "limit=101", "cursor=0", "cursor=9223372036854775808", "cursor=oops", "status=invalid", "type=invalid"),
)
def test_invalid_page_never_reads_database(client: TestClient, table: MagicMock, query: str) -> None:
assert client.get(f"/alerts?{query}").status_code == 422
@@ -134,7 +141,7 @@ def test_openapi_keeps_ids_and_cursors_as_strings(client: TestClient) -> None:
assert {s["type"] for s in cursor["schema"]["anyOf"]} == {"string", "null"}
schemas: Final = spec["components"]["schemas"]
assert schemas["Alert"]["properties"]["id"]["type"] == "string"
- assert {s["type"] for s in schemas["AlertPage"]["properties"]["next_cursor"]["anyOf"]} == {"string", "null"}
+ assert {s["type"] for s in schemas["AlertViewPage"]["properties"]["next_cursor"]["anyOf"]} == {"string", "null"}
def test_viewer_reads_saved_alert_with_string_id(app: FastAPI, table: MagicMock) -> None:
@@ -145,7 +152,10 @@ def test_viewer_reads_saved_alert_with_string_id(app: FastAPI, table: MagicMock)
response: Final = client.get("/alerts?status=open&limit=1")
assert response.status_code == 200, response.text
expected: Final = StoredAlert().model_dump(mode="json", exclude={"detection_key"})
- assert response.json() == {"data": [{**expected, "id": "5"}], "next_cursor": None}
+ assert response.json() == {
+ "data": [{**expected, "id": "5", "resource_name": None, "history": []}],
+ "next_cursor": None,
+ }
@pytest.mark.parametrize("method", ("GET", "PATCH"))
@@ -159,3 +169,82 @@ def test_missing_alert_returns_not_found(client: TestClient, method: str) -> Non
def test_status_update_rejects_other_changes(client: TestClient, table: MagicMock, body: object) -> None:
assert client.patch("/alerts/5", json=body).status_code == 422
table.update.assert_not_awaited()
+
+
+def test_empty_alert_page_does_not_read_usage(client: TestClient, table: MagicMock, history_query: AsyncMock) -> None:
+ table.find_many.return_value = []
+ response: Final = client.get("/alerts")
+ assert response.status_code == 200
+ assert response.json() == {"data": [], "next_cursor": None}
+ history_query.assert_not_awaited()
+
+
+@pytest.mark.parametrize(
+ "kind,unit,expected",
+ (
+ ("spend", "usd", 12),
+ ("traffic", "requests", 20),
+ ("latency", "milliseconds", 250),
+ ("reliability", "ratio", 0.25),
+ ),
+)
+def test_detail_returns_usage_history_without_replacing_saved_evidence(
+ client: TestClient, table: MagicMock, history_query: AsyncMock, kind: str, unit: str, expected: float
+) -> None:
+ evidence: Final = {"value": 0.5, "baseline": 0.1, "unit": unit, "sample_size": 50}
+ table.find_unique.return_value = StoredAlert(type=kind, evidence=evidence)
+ history_query.return_value = [
+ {
+ "id": "5",
+ "resource_name": "Research",
+ "date": "2026-01-08",
+ "spend": 12,
+ "requests": 20,
+ "failed": 5,
+ "response_ms": 3750,
+ "timed": 15,
+ }
+ ]
+ response: Final = client.get("/alerts/5")
+ assert response.status_code == 200
+ assert response.json()["resource_name"] == "Research"
+ assert response.json()["history"] == [{"date": "2026-01-08", "value": expected}]
+ assert response.json()["evidence"] == evidence
+
+
+@pytest.mark.parametrize("failure", (TimeoutError("private row value"), ValueError("private row value")))
+def test_history_failure_keeps_alert_readable_and_logs_only_error_type(
+ client: TestClient, table: MagicMock, history_query: AsyncMock, caplog: pytest.LogCaptureFixture, failure: Exception
+) -> None:
+ table.find_unique.return_value = StoredAlert()
+ history_query.side_effect = failure
+ response: Final = client.get("/alerts/5")
+ assert response.status_code == 200
+ assert response.json()["evidence"] == StoredAlert().evidence
+ assert response.json()["history"] is None
+ assert f"Alert Usage history unavailable ({type(failure).__name__})" in caplog.text
+ assert "private row value" not in caplog.text
+
+
+@pytest.mark.parametrize("kind,unit", (("latency", "milliseconds"), ("reliability", "ratio")))
+def test_no_samples_leave_a_gap_in_history(
+ client: TestClient, table: MagicMock, history_query: AsyncMock, kind: str, unit: str
+) -> None:
+ table.find_unique.return_value = StoredAlert(
+ type=kind, evidence={"value": 0.5, "baseline": 0.1, "unit": unit, "sample_size": 50}
+ )
+ history_query.return_value = [
+ {
+ "id": "5",
+ "resource_name": "Research",
+ "date": "2026-01-08",
+ "spend": 0,
+ "requests": 0,
+ "failed": 0,
+ "response_ms": 0,
+ "timed": 0,
+ }
+ ]
+ response: Final = client.get("/alerts/5")
+ assert response.status_code == 200
+ assert response.json()["history"] == []
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index e9bbeb94355..6755900f445 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1740,7 +1740,7 @@
"count": 5
},
"no-restricted-syntax": {
- "count": 132
+ "count": 131
},
"prefer-const": {
"count": 26
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
index e08cab776c4..2a0069f20f1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { renderWithProviders as render } from "../../../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
index 4eb3534ebe3..3f62ca35085 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
@@ -1,5 +1,10 @@
import React from "react";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { renderWithProviders as render } from "../../../../../tests/test-utils";
+import { parseAsString, useQueryStates } from "nuqs";
+import type { UrlUpdateEvent } from "nuqs/adapters/testing";
+import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
+import type { KeyResponse } from "@/components/key_team_helpers/key_list";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
import AgentFormFields from "./agent_form_fields";
@@ -14,7 +19,30 @@ vi.mock("@/components/networking", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
- useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
+ useKeys: vi.fn(() => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() })),
+}));
+
+vi.mock("@/components/templates/key_info_view", () => ({
+ default: function KeyInfoViewMock({ onClose, onDelete }: { onClose: () => void; onDelete: () => void }) {
+ const [, setActivity] = useQueryStates({
+ detail_tab: parseAsString,
+ activity_from: parseAsString,
+ activity_to: parseAsString,
+ });
+ return (
+
+
+
+
+
+ );
+ },
}));
vi.mock("./AgentIdentityDetails", () => ({
@@ -82,6 +110,11 @@ const agent = {
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.restoreAllMocks();
+ vi.mocked(useKeys).mockReturnValue({
+ data: { keys: [] },
+ isLoading: false,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
vi.mocked(AgentFormFields)
.mockReset()
.mockImplementation(() => );
@@ -91,6 +124,31 @@ describe("AgentInfoView settings", () => {
vi.mocked(networking.triggerAgentKillSwitchCall).mockReset();
});
+ it.each(["Back to agent", "Delete key"])("clears key tabs and dates when opening a key and on %s", async (action) => {
+ const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
+ const refetch = vi.fn();
+ vi.mocked(useKeys).mockReturnValue({
+ data: { keys: [{ token: "agent-key-1", key_alias: "Agent key" } as KeyResponse] },
+ isLoading: false,
+ refetch,
+ } as unknown as ReturnType);
+ render(, {
+ searchParams: "?agent=agent-1&detail_tab=activity&activity_from=2026-10-01&activity_to=2026-10-02",
+ onUrlUpdate,
+ });
+
+ fireEvent.click(await screen.findByRole("button", { name: "agent-key-1..." }));
+ expect(await screen.findByRole("button", { name: "Back to agent" })).toBeInTheDocument();
+ await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("agent=agent-1"));
+
+ fireEvent.click(screen.getByRole("button", { name: "Select activity" }));
+ await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("activity_from")).toBe("2026-11-01"));
+ fireEvent.click(screen.getByRole("button", { name: action }));
+ await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("agent=agent-1"));
+ expect(await screen.findByRole("button", { name: "agent-key-1..." })).toBeInTheDocument();
+ if (action === "Delete key") expect(refetch).toHaveBeenCalledOnce();
+ });
+
it("submits the edited agent when Save Changes is pressed", async () => {
render();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
index 554116716ea..253d31ceaf4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
@@ -51,6 +51,7 @@ import AgentCostView from "./agent_cost_view";
import { detectAgentType, parseDynamicAgentForForm } from "./agent_type_utils";
import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery";
import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils";
+import { useResetDetailState } from "@/hooks/useUrlTab";
interface AgentInfoViewProps {
agentId: string;
@@ -81,8 +82,13 @@ const DetailItem: React.FC<{ label: React.ReactNode; children: React.ReactNode }
const AgentInfoView: React.FC = ({ agentId, onClose, accessToken, isAdmin }) => {
const { t } = useTranslation();
+ const resetDetailState = useResetDetailState();
const [agent, setAgent] = useState(null);
const [selectedKey, setSelectedKey] = useState(null);
+ const closeKey = () => {
+ resetDetailState();
+ setSelectedKey(null);
+ };
const { data: keysData, isLoading: keysLoading, refetch: refetchAgentKeys } = useKeys(1, 100, { agentID: agentId });
const agentKeys = keysData?.keys ?? [];
const [isLoading, setIsLoading] = useState(true);
@@ -310,9 +316,9 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
setSelectedKey(null)}
+ onClose={closeKey}
onDelete={() => {
- setSelectedKey(null);
+ closeKey();
refetchAgentKeys();
}}
teams={null}
@@ -424,7 +430,14 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
{formatDate(agent.updated_at)}
-
+ {
+ resetDetailState();
+ setSelectedKey(key);
+ }}
+ />
{agent.object_permission &&
(agent.object_permission.mcp_servers?.length ||
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx
index fe4692192d8..9506d21d6f4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx
@@ -1,21 +1,5 @@
-"use client";
-
-import { useTranslation } from "react-i18next";
-import { Page, PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
-import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
+import Alerts from "@/components/Alerts/Alerts";
export default function AlertsPage() {
- const { t } = useTranslation();
- return (
-
-
-
- {t("alerts.page.tabs.modelDeprecations")}
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
index 204c4b1a409..a9aaf8dd848 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
@@ -1,5 +1,6 @@
+import { renderWithProviders as render } from "../../../../../tests/test-utils";
import React from "react";
-import { fireEvent, render, waitFor, screen } from "@testing-library/react";
+import { fireEvent, waitFor, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
index a2f0ca5edc9..39b7428ea76 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
@@ -1,5 +1,6 @@
+import { renderWithProviders as render } from "../../../../../tests/test-utils";
import React from "react";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx
index 90d6694ccba..3877216a424 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx
@@ -1,9 +1,13 @@
-import { renderHook } from "@testing-library/react";
+import { NuqsTestingAdapter } from "nuqs/adapters/testing";
+import { act, renderHook as testingRenderHook, waitFor } from "@testing-library/react";
+import { type PropsWithChildren } from "react";
import { describe, expect, it, vi } from "vitest";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
const mockUseAggregatedDailyActivity = vi.fn();
+const renderHook: typeof testingRenderHook = (render, options) =>
+ testingRenderHook(render, { ...options, wrapper: NuqsTestingAdapter });
vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({
useAggregatedDailyActivity: (options: unknown) => {
@@ -87,3 +91,39 @@ describe("useDailyActivityRange", () => {
});
});
});
+
+it("opens the alert's complete UTC date window", () => {
+ const { result } = testingRenderHook(() => useActivityDateRange(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+ expect(result.current.dateValue.from?.getDate()).toBe(1);
+ expect(result.current.dateValue.to?.getDate()).toBe(8);
+ expect(result.current.dateValue.from?.getFullYear()).toBe(2026);
+});
+
+it("updates an already mounted Activity view when another alert changes the URL dates", async () => {
+ let searchParams = "activity_from=2026-01-01&activity_to=2026-01-08";
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ );
+ const { result, rerender } = testingRenderHook(() => useActivityDateRange(), { wrapper });
+ searchParams = "activity_from=2026-02-01&activity_to=2026-02-08";
+ rerender();
+ await waitFor(() => expect(result.current.dateValue.from).toEqual(new Date(2026, 1, 1, 12)));
+ expect(result.current.dateValue.to).toEqual(new Date(2026, 1, 8, 12));
+});
+
+it("updates the dates when the user changes the date picker", async () => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ );
+ const { result } = testingRenderHook(() => useActivityDateRange(), { wrapper });
+ await act(async () => result.current.onDateChange({ from: new Date(2026, 2, 1, 12), to: new Date(2026, 2, 8, 12) }));
+ expect(result.current.dateValue.from).toEqual(new Date(2026, 2, 1, 12));
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts
index 1af009397b1..eec33e54fc4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts
@@ -1,4 +1,5 @@
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
+import { createParser, useQueryStates } from "nuqs";
import { dailyActivityAggregatedCall } from "@/components/networking";
import {
@@ -12,6 +13,15 @@ import { spendScopeUserId } from "@/utils/roles";
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
+const activityDate = createParser({
+ parse: (value) => {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
+ const date = new Date(`${value}T12:00:00`);
+ return Number.isFinite(date.getTime()) && date.toLocaleDateString("en-CA") === value ? date : null;
+ },
+ serialize: (value) => value.toLocaleDateString("en-CA"),
+});
+const activityDates = { activity_from: activityDate, activity_to: activityDate };
export interface DateRange {
from?: Date;
@@ -41,8 +51,18 @@ export type ActivityDateRange = Pick {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
- const [dateValue, setDateValue] = useState({ from: initialFrom, to: initialTo });
- return { dateValue, onDateChange: setDateValue };
+ const [params, setParams] = useQueryStates(activityDates);
+ const dateValue = useMemo(
+ () =>
+ params.activity_from && params.activity_to && params.activity_from <= params.activity_to
+ ? { from: params.activity_from, to: params.activity_to }
+ : { from: initialFrom, to: initialTo },
+ [params.activity_from, params.activity_to, initialFrom, initialTo],
+ );
+ const onDateChange = (value: DateRange) => {
+ void setParams({ activity_from: value.from ?? null, activity_to: value.to ?? null });
+ };
+ return { dateValue, onDateChange };
};
export interface ScopedActivityInput {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts
index c4ea206022c..5c28fed2723 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts
@@ -46,6 +46,29 @@ describe("useModelDetailRouting", () => {
expect(event?.searchParams.has("team")).toBe(false);
});
+ it.each(["openModel", "openTeam", "close"] as const)(
+ "%s clears the previous resource's tab and dates",
+ async (action) => {
+ const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
+ const { result } = renderHook(() => useModelDetailRouting(), {
+ wrapper: withNuqsTestingAdapter({
+ searchParams:
+ "?model=old&detail_tab=activity&key_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08&tab=all-models",
+ onUrlUpdate,
+ }),
+ });
+ await act(async () => {
+ result.current[action]("next");
+ });
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
+ const identity = action === "close" ? {} : { [action === "openModel" ? "model" : "team"]: "next" };
+ expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({
+ ...identity,
+ tab: "all-models",
+ });
+ },
+ );
+
it("reads modelId and teamId from the query string", () => {
const { result } = renderHook(() => useModelDetailRouting(), {
wrapper: withNuqsTestingAdapter({ searchParams: "?model=xyz" }),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts
index e83a81a53cf..2c70f184b63 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts
@@ -1,5 +1,6 @@
import { parseAsString, useQueryState, useQueryStates } from "nuqs";
import { useCallback } from "react";
+import { useResetDetailState } from "@/hooks/useUrlTab";
export interface ModelDetailRouting {
modelId: string | null;
@@ -10,6 +11,7 @@ export interface ModelDetailRouting {
}
export function useModelDetailRouting(): ModelDetailRouting {
+ const resetDetailState = useResetDetailState();
const [{ model, team }, setParams] = useQueryStates(
{ model: parseAsString, team: parseAsString },
{ history: "push" },
@@ -17,21 +19,24 @@ export function useModelDetailRouting(): ModelDetailRouting {
const openModel = useCallback(
(id: string) => {
+ resetDetailState();
void setParams({ model: id, team: null });
},
- [setParams],
+ [setParams, resetDetailState],
);
const openTeam = useCallback(
(id: string) => {
+ resetDetailState();
void setParams({ model: null, team: id });
},
- [setParams],
+ [setParams, resetDetailState],
);
const close = useCallback(() => {
+ resetDetailState();
void setParams({ model: null, team: null });
- }, [setParams]);
+ }, [setParams, resetDetailState]);
return {
modelId: model,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
index d728f6473f5..e1166f94672 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
+import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
@@ -33,8 +34,21 @@ vi.mock("@/components/networking", () => ({
// The detail view has its own test; stub it so this file covers the parent's swap.
vi.mock("./view_users/user_info_view", () => ({
- default: function UserInfoViewMock({ userId, startInEditMode }: { userId: string; startInEditMode?: boolean }) {
- return {`detail:${userId}:${String(Boolean(startInEditMode))}`}
;
+ default: function UserInfoViewMock({
+ userId,
+ startInEditMode,
+ onClose,
+ }: {
+ userId: string;
+ startInEditMode?: boolean;
+ onClose: () => void;
+ }) {
+ return (
+
+ {`detail:${userId}:${String(Boolean(startInEditMode))}`}
+
+
+ );
},
}));
@@ -85,11 +99,15 @@ const defaultProps = {
teams: [],
};
-const renderDashboard = (overrides: Partial = {}) =>
+const renderDashboard = (
+ overrides: Partial = {},
+ options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction },
+) =>
renderWithProviders(
,
+ options,
);
describe("ViewUserDashboard", () => {
@@ -196,7 +214,11 @@ describe("ViewUserDashboard", () => {
it("should swap to the detail view when the identity cell is clicked", async () => {
const user = userEvent.setup();
- renderDashboard();
+ const onUrlUpdate = vi.fn();
+ renderDashboard(
+ {},
+ { searchParams: "?detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08", onUrlUpdate },
+ );
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
@@ -206,11 +228,17 @@ describe("ViewUserDashboard", () => {
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false");
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
+ expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ user: "user-1" });
});
it("should open the detail view in edit mode from the row actions menu", async () => {
const user = userEvent.setup();
- renderDashboard();
+ const onUrlUpdate = vi.fn();
+ renderDashboard(
+ {},
+ { searchParams: "?detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08", onUrlUpdate },
+ );
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
@@ -220,6 +248,22 @@ describe("ViewUserDashboard", () => {
await user.click(await screen.findByTestId("user-action-edit"));
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:true");
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
+ expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ user: "user-1" });
+ });
+
+ it("closing a user clears its tab and dates while preserving list filters", async () => {
+ const onUrlUpdate = vi.fn();
+ renderDashboard(
+ {},
+ {
+ searchParams: "?user=user-1&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08&tab=users",
+ onUrlUpdate,
+ },
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Close user" }));
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
+ expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ tab: "users" });
});
describe("bulk edit selection", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
index 610933e7fa0..1e40bbaa594 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
@@ -1,4 +1,5 @@
import { parseAsString, useQueryState } from "nuqs";
+import { useResetDetailState } from "@/hooks/useUrlTab";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -73,6 +74,7 @@ const ViewUserDashboard: React.FC = ({
const [selectionMode, setSelectionMode] = useState(false);
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
+ const resetDetailState = useResetDetailState();
const [selectedUserId, setSelectedUserId] = useQueryState("user", parseAsString.withOptions({ history: "push" }));
const [openInEditMode, setOpenInEditMode] = useState(false);
@@ -140,16 +142,18 @@ const ViewUserDashboard: React.FC = ({
const handleUserClick = useCallback(
(userId: string, openInEdit: boolean = false) => {
+ resetDetailState();
void setSelectedUserId(userId);
setOpenInEditMode(openInEdit);
},
- [setSelectedUserId],
+ [setSelectedUserId, resetDetailState],
);
const handleCloseUserInfo = useCallback(() => {
+ resetDetailState();
void setSelectedUserId(null);
setOpenInEditMode(false);
- }, [setSelectedUserId]);
+ }, [setSelectedUserId, resetDetailState]);
const handleDelete = useCallback((user: UserInfo) => {
setUserToDelete(user);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx
index e78e4abe42c..5e2ca319091 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx
@@ -418,7 +418,11 @@ describe("UserInfoView auto-router usage", () => {
Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }),
);
mockFetch.mockReset().mockResolvedValue(Response.json(routerUsageResponse(42)));
- vi.stubGlobal("fetch", mockFetch);
+ vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input instanceof Request ? input.url : String(input);
+ if (url.includes("/telemetry/")) return Promise.resolve(Response.json({ enabled: false }));
+ return mockFetch(input, init);
+ });
});
afterEach(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
index 3a29578c5c2..44d964bf13a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { renderWithProviders as render } from "../../../../../../tests/test-utils";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UserInfoView from "./user_info_view";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
index ca2c4264213..78c1875bb1d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
@@ -1,3 +1,4 @@
+import { useUrlTab } from "@/hooks/useUrlTab";
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import { MemberRoleSelect } from "./MemberRoleSelect";
import React, { useState } from "react";
@@ -99,7 +100,11 @@ export default function UserInfoView({
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState(null);
const [baseUrl, setBaseUrl] = useState(null);
- const [activeTab, setActiveTab] = useState(initialTab === 1 ? "details" : "overview");
+ const [activeTab, setActiveTab] = useUrlTab(
+ ["overview", "details", "activity", "savings", "auto-router-usage"],
+ initialTab === 1 ? "details" : "overview",
+ "detail_tab",
+ );
const hiddenUsageTab = (activeTab === "savings" || activeTab === "activity") && !canViewUserUsage;
const hiddenRouterTab = activeTab === "auto-router-usage" && !canViewAutoRouterUsage;
const [copiedStates, setCopiedStates] = useState>({});
@@ -484,7 +489,7 @@ export default function UserInfoView({
setActiveTab(String(v))}
+ onValueChange={(v: unknown) => setActiveTab(String(v) as typeof activeTab)}
className="gap-0"
>
diff --git a/ui/litellm-dashboard/src/components/Alerts/AlertActions.tsx b/ui/litellm-dashboard/src/components/Alerts/AlertActions.tsx
new file mode 100644
index 00000000000..562cc733a7b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/AlertActions.tsx
@@ -0,0 +1,89 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Check, RotateCcw, X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { backgroundFetchClient } from "@/lib/http/api";
+import { AlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
+import { AlertItem, presentAlert } from "./alertPresentation";
+
+export default function AlertActions({ item, readOnly }: { item: AlertItem; readOnly: boolean }) {
+ const { t } = useTranslation();
+ const client = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: async (status: AlertStatus) => {
+ if (item.source === "performance") {
+ const result = await backgroundFetchClient.PATCH("/alerts/{alert_id}", {
+ params: { path: { alert_id: item.alert.id } },
+ body: { status },
+ });
+ if (!result.data) throw new Error("Could not save alert status");
+ return;
+ }
+ const alert = item.alert;
+ const result = await backgroundFetchClient.PUT("/alerting/model-deprecations/state", {
+ body: {
+ model: alert.usageModel,
+ deprecation_date: alert.model.deprecation_date,
+ status,
+ last_seen_date: alert.lastUsedDate,
+ last_seen_requests: alert.lastDayRequests,
+ },
+ });
+ if (!result.data) throw new Error("Could not save alert status");
+ },
+ onSuccess: () =>
+ Promise.all(
+ (item.source === "performance" ? ["alerts", "alert"] : ["deprecationAlerts"]).map((key) =>
+ client.invalidateQueries({ queryKey: [key] }),
+ ),
+ ),
+ });
+ const status = presentAlert(item).status;
+ if (readOnly) return {t(`alerts.status.${status}`)};
+ return (
+
+
+ {status === "open" ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {t(`alerts.status.${status}`)}
+
+ >
+ )}
+
+ {mutation.isError && (
+
+ {t("alerts.unified.saveError")}
+
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Alerts/AlertChart.tsx b/ui/litellm-dashboard/src/components/Alerts/AlertChart.tsx
new file mode 100644
index 00000000000..602f0231bf7
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/AlertChart.tsx
@@ -0,0 +1,123 @@
+import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, XAxis, YAxis } from "recharts";
+import { useTranslation } from "react-i18next";
+import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
+import { AlertPresentation, dateLabel, metricValue } from "./alertPresentation";
+
+export default function AlertChart({ alert, compact = false }: { alert: AlertPresentation; compact?: boolean }) {
+ const { t } = useTranslation();
+ if (!alert.history?.length)
+ return compact ? null : (
+ {t("alerts.unified.chart.unavailable")}
+ );
+ const end = Date.parse(alert.observed);
+ const start =
+ alert.baseline === undefined
+ ? Math.min(...alert.history.map((point) => Date.parse(point.date)))
+ : end - 7 * 86400000;
+ const values = new Map(alert.history.map((point) => [point.date, point.value]));
+ const data = Array.from({ length: Math.min(31, Math.round((end - start) / 86400000) + 1) }, (_, index) => {
+ const date = new Date(start + index * 86400000).toISOString().slice(0, 10);
+ return { date, value: values.get(date) ?? null };
+ });
+ return (
+
+ {!compact && (
+
+
{alert.chartTitle}
+
+ {dateLabel(data[0].date)} – {dateLabel(alert.observed)} UTC
+
+
+ )}
+
+
+ {!compact && }
+
+ new Date(`${date}T00:00:00Z`).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+ })
+ }
+ />
+ metricValue(value, alert.unit)}
+ />
+ dateLabel(String(value))}
+ formatter={(value) => metricValue(Number(value), alert.unit)}
+ />
+ }
+ />
+ {!compact && alert.baseline !== undefined && (
+
+ )}
+
+ {data.map((point) => (
+ |
+ ))}
+
+
+
+ {!compact && (
+ <>
+
+ {alert.baseline !== undefined &&
+ t("alerts.unified.chart.baseline", { value: metricValue(alert.baseline, alert.unit) })}
+ {t("alerts.unified.chart.missingDays")}
+ {alert.baseline !== undefined && t("alerts.unified.chart.currentUsage")}
+
+
+
+
+ | {t("alerts.unified.chart.date")} |
+ {t("alerts.unified.chart.value")} |
+
+
+
+ {data.map((point) => (
+
+ | {point.date} |
+
+ {point.value === null
+ ? t("alerts.migrationDetail.notRecorded")
+ : metricValue(point.value, alert.unit)}
+ |
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Alerts/AlertDetail.tsx b/ui/litellm-dashboard/src/components/Alerts/AlertDetail.tsx
new file mode 100644
index 00000000000..733641fdb17
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/AlertDetail.tsx
@@ -0,0 +1,83 @@
+import { ArrowLeft, KeyRound, Network, Server, User, Users } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { buttonVariants } from "@/components/ui/button";
+import RecommendedReplacement from "@/components/ModelDeprecationAlerts/RecommendedReplacement";
+import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
+import AlertActions from "./AlertActions";
+import AlertChart from "./AlertChart";
+import { AlertItem, dateLabel, presentAlert } from "./alertPresentation";
+
+export default function AlertDetail({
+ item,
+ readOnly,
+ onBack,
+}: {
+ item: AlertItem;
+ readOnly: boolean;
+ onBack: () => void;
+}) {
+ const { t } = useTranslation();
+ const alert = presentAlert(item);
+ const Icon = { key: KeyRound, team: Users, user: User, model: Network, gateway: Server }[alert.resourceType];
+ return (
+
+
+
+
+
+ {alert.description}
+
+
+
+ {t("alerts.unified.resourceHeading")}
+
+
+ {item.source === "deprecation" && (
+
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Alerts/Alerts.integration.test.tsx b/ui/litellm-dashboard/src/components/Alerts/Alerts.integration.test.tsx
new file mode 100644
index 00000000000..2b7c7c0d93b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/Alerts.integration.test.tsx
@@ -0,0 +1,152 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen, testQueryClient, within } from "../../../tests/test-utils";
+import Alerts from "./Alerts";
+import type { SavedAlert } from "./alertPresentation";
+
+vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
+let role = "proxy_admin";
+const alert: SavedAlert = {
+ id: "9007199254740993",
+ type: "spend",
+ resource_type: "key",
+ resource_id: "a".repeat(64),
+ resource_name: "Research agent",
+ status: "open",
+ window_start: "2026-01-08T00:00:00Z",
+ window_end: "2026-01-09T00:00:00Z",
+ created_at: "2026-01-09T03:00:00Z",
+ updated_at: "2026-01-09T03:00:00Z",
+ evidence: { value: 20, baseline: 2, unit: "usd", sample_size: 100 },
+ history: [
+ { date: "2026-01-01", value: 2 },
+ { date: "2026-01-08", value: 21 },
+ ],
+};
+const calls: { path: string; query: URLSearchParams; method: string; body: unknown }[] = [];
+let status = "open";
+let rejectWrite = false;
+let rejectHistory = false;
+function response(data: unknown, code = 200) {
+ return new Response(JSON.stringify(data), { status: code, headers: { "Content-Type": "application/json" } });
+}
+
+describe("Alerts", () => {
+ beforeEach(() => {
+ testQueryClient.clear();
+ calls.length = 0;
+ status = "open";
+ rejectWrite = false;
+ rejectHistory = 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) => {
+ const request = input instanceof Request ? input : new Request(input);
+ const url = new URL(request.url);
+ const body = request.method === "PATCH" ? await request.clone().json() : null;
+ const call = { path: url.pathname, query: url.searchParams, method: request.method, body };
+ calls.push(call);
+ if (url.pathname === "/litellm/.well-known/litellm-ui-config") return response({});
+ if (url.pathname === "/management/v1/users/me") return response({ user_id: "example-admin", user_role: role });
+ if (url.pathname === "/model/deprecations") return response({ deprecated: [], imminent: [] });
+ if (request.method === "PATCH") {
+ if (rejectWrite) return response({ detail: "Unavailable" }, 503);
+ status = body.status;
+ return response({ ...alert, status });
+ }
+ if (url.pathname === `/alerts/${alert.id}`)
+ return response({ ...alert, status, history: rejectHistory ? null : alert.history });
+ if (url.pathname === "/alerts") {
+ if (url.searchParams.has("cursor"))
+ return response({ data: [{ ...alert, id: "7", resource_name: "Second key" }], next_cursor: null });
+ return response({
+ data: url.searchParams.get("status") === status ? [{ ...alert, status }] : [],
+ next_cursor: "8",
+ });
+ }
+ throw new Error(`Unexpected endpoint ${url.pathname}`);
+ });
+ });
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.cookie = "token=; Max-Age=0; path=/";
+ });
+
+ it("shows saved evidence, daily gaps, and a scoped Activity link", async () => {
+ renderWithProviders(, { searchParams: { alert: alert.id } });
+ expect(
+ await screen.findByRole("heading", { name: "Spend on Research agent rose to 10× its baseline" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/\$20.00 on/)).toBeInTheDocument();
+ const chart = screen.getByRole("table", { name: "Daily spend values" });
+ expect(within(chart).getByRole("row", { name: "2026-01-08 $21.00" })).toBeInTheDocument();
+ expect(within(chart).getByRole("row", { name: "2026-01-02 Not recorded" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View key activity" })).toHaveAttribute(
+ "href",
+ `/ui/api-keys?key=${alert.resource_id}&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08`,
+ );
+ });
+
+ it("keeps saved evidence visible when daily history cannot be loaded", async () => {
+ rejectHistory = true;
+ renderWithProviders(, { searchParams: { alert: alert.id } });
+ expect(await screen.findByText(/Daily history is unavailable/)).toBeInTheDocument();
+ expect(screen.getByText(/\$20.00 on/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Mark resolved" })).toBeEnabled();
+ });
+
+ it("saves status and reloads the detail, retaining exact large IDs", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { searchParams: { alert: alert.id } });
+ await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
+ expect(await screen.findByRole("button", { name: "Reopen" })).toBeInTheDocument();
+ expect(calls.find((call) => call.method === "PATCH")).toMatchObject({
+ path: `/alerts/${alert.id}`,
+ body: { status: "resolved" },
+ });
+ await user.click(screen.getByRole("button", { name: "Reopen" }));
+ expect(await screen.findByRole("button", { name: "Dismiss" })).toBeInTheDocument();
+ });
+
+ it("retains the open alert when a status write fails", async () => {
+ rejectWrite = true;
+ const user = userEvent.setup();
+ renderWithProviders(, { searchParams: { alert: alert.id } });
+ await user.click(await screen.findByRole("button", { name: "Dismiss" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not save status");
+ expect(screen.getByRole("button", { name: "Mark resolved" })).toBeEnabled();
+ });
+
+ it("uses the selected type and cursor when loading more results", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { searchParams: { type: "spend" } });
+ await user.click(await screen.findByRole("button", { name: "Load more" }));
+ expect(await screen.findByRole("button", { name: /Spend on Second key/ })).toBeInTheDocument();
+ expect(
+ calls.filter((call) => call.path === "/alerts").map((call) => [call.query.get("type"), call.query.get("cursor")]),
+ ).toEqual([
+ ["spend", null],
+ ["spend", "8"],
+ ]);
+ await user.click(screen.getByRole("tab", { name: "resolved" }));
+ expect(await screen.findByText("No resolved alerts.")).toBeInTheDocument();
+ });
+
+ it("keeps deprecation email setup accessible from the inbox", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(await screen.findByRole("button", { name: "Set up email reminders" }));
+ expect(screen.getByRole("link", { name: "Open Email Settings" })).toHaveAttribute(
+ "href",
+ "/ui/logging-and-alerts?tab=email-alerts",
+ );
+ });
+
+ it("lets viewers read the evidence without write controls", async () => {
+ role = "proxy_admin_viewer";
+ renderWithProviders(, { searchParams: { alert: alert.id } });
+ expect(await screen.findByRole("heading", { name: /Spend on Research agent/ })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Mark resolved" })).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Alerts/Alerts.tsx b/ui/litellm-dashboard/src/components/Alerts/Alerts.tsx
new file mode 100644
index 00000000000..2f9d502741b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/Alerts.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { parseAsBoolean, parseAsString, parseAsStringEnum, parseAsStringLiteral, useQueryStates } from "nuqs";
+import { RefreshCw } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { isProxyAdminTierRole } from "@/utils/roles";
+import { useDeprecationAlerts } from "@/components/ModelDeprecationAlerts/useDeprecationAlerts";
+import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
+import DeprecationEmailSetup from "@/components/ModelDeprecationAlerts/DeprecationEmailSetup";
+import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
+import { useAlert, useAlerts } from "./useAlerts";
+import { ALERT_TYPES, AlertFilter, AlertItem, TYPE_LABEL_KEYS, presentAlert } from "./alertPresentation";
+import AlertActions from "./AlertActions";
+import AlertChart from "./AlertChart";
+import AlertDetail from "./AlertDetail";
+
+const parsers = {
+ alert: parseAsString,
+ model: parseAsString,
+ migration: parseAsBoolean.withDefault(false),
+ state: parseAsStringEnum(Object.values(AlertStatus)).withDefault(AlertStatus.Open),
+ type: parseAsStringLiteral(ALERT_TYPES).withDefault("all"),
+};
+function LoadError({ children, retry }: { children: React.ReactNode; retry: () => void }) {
+ const { t } = useTranslation();
+ return (
+
+
{children}
+
+
+ );
+}
+export default function Alerts() {
+ const { t } = useTranslation();
+ const auth = useAuthorized();
+ const [params, setParams] = useQueryStates(parsers);
+ const allowed = Boolean(auth.isAuthorized && isProxyAdminTierRole(auth.userRole));
+ if (auth.isLoading)
+ return (
+
+ {t("alerts.unified.loading")}
+
+ );
+ if (!allowed)
+ return (
+
+ {t("alerts.unified.adminRequired")}
+
+ );
+ return (
+
+ {params.alert || params.model ? (
+ void setParams({ alert: null, model: null, migration: null }, { history: "push" })}
+ />
+ ) : (
+
+ )}
+
+ );
+}
+
+function SelectedAlert({ auth, onBack }: { auth: ReturnType; onBack: () => void }) {
+ const { t } = useTranslation();
+ const [params] = useQueryStates(parsers);
+ const scope = JSON.stringify([auth.userId, auth.userRole]);
+ const detail = useAlert(params.alert, scope, true);
+ const deprecations = useDeprecationAlerts(params.model ? auth.accessToken : null);
+ const selectedDeprecation = deprecations.data?.find((alert) => alert.usageModel === params.model);
+ const selected: AlertItem | undefined = params.alert
+ ? detail.data && { source: "performance", alert: detail.data }
+ : selectedDeprecation && { source: "deprecation", alert: selectedDeprecation };
+ if (params.migration && params.model) return ;
+ const query = params.alert ? detail : deprecations;
+ if (selected && !query.isError)
+ return ;
+ return (
+ <>
+
+ {query.isError ? (
+ void query.refetch()}>{t("alerts.unified.loadError")}
+ ) : (
+ {query.isPending ? t("alerts.panel.loadingAlert") : t("alerts.unified.unavailable")}
+ )}
+ >
+ );
+}
+
+function AlertsInbox({ auth }: { auth: ReturnType }) {
+ const { t } = useTranslation();
+ const [params, setParams] = useQueryStates(parsers);
+ const scope = JSON.stringify([auth.userId, auth.userRole]);
+ const showDeprecations = params.type === "all" || params.type === "model-deprecations";
+ const showPerformance = params.type !== "model-deprecations";
+ const list = useAlerts(scope, showPerformance, params.state, params.type);
+ const deprecations = useDeprecationAlerts(showDeprecations ? auth.accessToken : null);
+ const retirementItems: AlertItem[] = (deprecations.data ?? [])
+ .filter((alert) => effectiveAlertStatus(alert) === params.state)
+ .map((alert) => ({ source: "deprecation", alert }));
+ const performanceItems: AlertItem[] = (list.data?.pages.flatMap((page) => page.data) ?? []).map((alert) => ({
+ source: "performance",
+ alert,
+ }));
+ const items = [...retirementItems, ...performanceItems];
+ const loading = list.isLoading || deprecations.isLoading;
+ const hasError = (showPerformance && list.isError) || (showDeprecations && deprecations.isError);
+ return (
+ <>
+ {t("alerts.unified.title")}
+ void setParams({ state: value as AlertStatus })}
+ className="flex-none"
+ >
+
+ {Object.values(AlertStatus).map((status) => (
+
+ {t(`alerts.status.${status}`)}
+
+ ))}
+
+
+
+
+
+
+ {showDeprecations && deprecations.isError && (
+ void deprecations.refetch()}>{t("alerts.unified.deprecationsError")}
+ )}
+ {showPerformance && list.isError && (
+ void list.refetch()}>{t("alerts.unified.performanceError")}
+ )}
+
+
+ {items.map((item) => {
+ const alert = presentAlert(item);
+ return (
+ -
+
+
+
{alert.description}
+
+
+
+
+ );
+ })}
+
+ {showPerformance && list.hasNextPage && (
+
+ )}
+ {showDeprecations && }
+ >
+ );
+}
+
+function InboxNotice({
+ loading,
+ error,
+ count,
+ status,
+}: {
+ loading: boolean;
+ error: boolean;
+ count: number;
+ status: AlertStatus;
+}) {
+ const { t } = useTranslation();
+ if (count > 0 || error) return null;
+ return (
+
+ {loading ? t("alerts.unified.loading") : t(`alerts.unified.empty.${status}`)}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Alerts/alertPresentation.test.ts b/ui/litellm-dashboard/src/components/Alerts/alertPresentation.test.ts
new file mode 100644
index 00000000000..aa92ca8fc20
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/alertPresentation.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { activityLink, presentAlert, type SavedAlert } from "./alertPresentation";
+
+const saved: SavedAlert = {
+ id: "1",
+ type: "spend",
+ resource_type: "team",
+ resource_id: "example-team",
+ status: "open",
+ window_start: "2026-01-08T00:00:00Z",
+ window_end: "2026-01-09T00:00:00Z",
+ created_at: "2026-01-09T03:00:00Z",
+ updated_at: "2026-01-09T03:00:00Z",
+ evidence: { value: 20, baseline: 2, unit: "usd", sample_size: 100 },
+};
+
+describe("alert presentation", () => {
+ it.each(
+ (
+ [
+ ["spend", "usd", 20, 2, "Spend", "$20.00"],
+ ["traffic", "requests", 1000, 100, "Traffic", "1,000"],
+ ["latency", "milliseconds", 4000, 1000, "Mean response time", "4 s"],
+ ["reliability", "ratio", 0.2, 0.01, "Error rate", "20%"],
+ ] as const
+ ).map((row) => [row] as const),
+ )("labels %s with the recorded units", ([type, unit, value, baseline, label, formatted]) => {
+ const alert = { ...saved, type, evidence: { value, baseline, unit, sample_size: 100 } };
+ const presentation = presentAlert({ source: "performance", alert });
+ expect(presentation.title).toContain(`${label} on example-team`);
+ expect(presentation.description).toContain(formatted);
+ expect(presentation.baseline).toBe(baseline);
+ });
+ it("links to the resource with an inclusive eight-day window", () => {
+ expect(activityLink(saved)).toBe(
+ "/ui/teams?team=example-team&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08",
+ );
+ expect(activityLink({ ...saved, resource_type: "user", resource_id: "reader@example.com" })).toContain(
+ "user=reader%40example.com&detail_tab=activity",
+ );
+ expect(activityLink({ ...saved, resource_type: "gateway", resource_id: "gateway" })).toBe(
+ "/ui/usage?from=2026-01-01&to=2026-01-08",
+ );
+ });
+ it("links model groups to routing instead of an unrelated deployment's Activity", () => {
+ const presentation = presentAlert({
+ source: "performance",
+ alert: { ...saved, resource_type: "model", resource_id: "research/model" },
+ });
+ expect(presentation.href).toBe("/ui/models-and-endpoints?model_group=research%2Fmodel");
+ expect(presentation.linkLabel).toBe("View model routing");
+ });
+ it("keeps the saved comparison when current Usage has changed", () => {
+ const presentation = presentAlert({
+ source: "performance",
+ alert: { ...saved, resource_name: "Research", history: [{ date: "2026-01-08", value: 40 }] },
+ });
+ expect(presentation.description).toContain("$20.00");
+ expect(presentation.history?.[0].value).toBe(40);
+ expect(presentation.title).toContain("Research");
+ });
+ it("shows retirement and last-used dates without inventing a detection date", () => {
+ const presentation = presentAlert({
+ source: "deprecation",
+ alert: {
+ id: "retirement",
+ usageModel: "example/retiring-model",
+ requests: 20,
+ lastDayRequests: 20,
+ lastUsedDate: "2026-01-08",
+ model: {
+ model_name: "example-group",
+ deprecation_date: "2026-01-31",
+ days_until_deprecation: 23,
+ status: "imminent",
+ },
+ },
+ });
+ expect(presentation.dates).toEqual([
+ ["Retires", "2026-01-31"],
+ ["Last used", "2026-01-08"],
+ ]);
+ expect(presentation.status).toBe("open");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Alerts/alertPresentation.ts b/ui/litellm-dashboard/src/components/Alerts/alertPresentation.ts
new file mode 100644
index 00000000000..79a5d84a2a4
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/alertPresentation.ts
@@ -0,0 +1,166 @@
+import type { components } from "@/lib/http/schema";
+import i18n from "@/i18n/i18n";
+import type { DeprecationAlert } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
+import { effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
+import { keyDetailHref, teamDetailHref, userDetailHref, modelGroupHref } from "@/utils/entityLinks";
+import { uiHref } from "@/utils/uiHref";
+
+export type SavedAlert = components["schemas"]["AlertView"];
+export type AlertItem =
+ | { source: "performance"; alert: SavedAlert }
+ | { source: "deprecation"; alert: DeprecationAlert };
+export type MetricUnit = "usd" | "requests" | "milliseconds" | "ratio";
+export type HistoryPoint = { date: string; value: number | null };
+export const ALERT_TYPES = [
+ "all",
+ "performance",
+ "model-deprecations",
+ "spend",
+ "traffic",
+ "latency",
+ "reliability",
+] as const;
+export type AlertFilter = (typeof ALERT_TYPES)[number];
+export const TYPE_LABEL_KEYS = {
+ all: "alerts.unified.types.all",
+ performance: "alerts.unified.types.performance",
+ "model-deprecations": "alerts.page.tabs.modelDeprecations",
+ spend: "alerts.unified.types.spend",
+ traffic: "alerts.unified.types.traffic",
+ latency: "alerts.unified.types.latency",
+ reliability: "alerts.unified.types.reliability",
+} as const;
+const dateFormat = { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" } as const;
+export const dateLabel = (value: string) =>
+ new Date(value.length === 10 ? `${value}T00:00:00Z` : value).toLocaleDateString(undefined, dateFormat);
+export function metricValue(value: number, unit: MetricUnit): string {
+ if (unit === "usd") return new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }).format(value);
+ if (unit === "ratio") return `${(value * 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
+ if (unit === "milliseconds") return `${(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 2 })} s`;
+ return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
+}
+
+export function activityLink(alert: SavedAlert): string | undefined {
+ const href = (() => {
+ switch (alert.resource_type) {
+ case "key":
+ return keyDetailHref(alert.resource_id);
+ case "team":
+ return teamDetailHref(alert.resource_id);
+ case "user":
+ return userDetailHref(alert.resource_id);
+ case "model":
+ return modelGroupHref(alert.resource_id);
+ case "gateway":
+ return uiHref("usage");
+ }
+ })();
+ if (!href || alert.resource_type === "model") return href;
+ const from = new Date(Date.parse(alert.window_start) - 7 * 86400000).toISOString().slice(0, 10);
+ const to = alert.window_start.slice(0, 10);
+ const query =
+ alert.resource_type === "gateway"
+ ? `from=${from}&to=${to}`
+ : `detail_tab=activity&activity_from=${from}&activity_to=${to}`;
+ return `${href}${href.includes("?") ? "&" : "?"}${query}`;
+}
+
+export function presentAlert(item: AlertItem) {
+ if (item.source === "deprecation") {
+ const alert = item.alert;
+ return {
+ id: alert.id,
+ title: i18n.t(
+ alert.model.status === "deprecated" ? "alerts.unified.retiredTitle" : "alerts.unified.retiresTitle",
+ {
+ name: alert.usageModel,
+ date: dateLabel(alert.model.deprecation_date),
+ },
+ ),
+ description: i18n.t("alerts.unified.deprecationDescription", {
+ requests: alert.requests.toLocaleString(),
+ date: dateLabel(alert.lastUsedDate),
+ }),
+ status: effectiveAlertStatus(alert),
+ resourceType: "model" as const,
+ resourceName: alert.usageModel,
+ href: modelGroupHref(alert.model.model_name),
+ linkLabel: i18n.t("alerts.unified.links.model"),
+ unit: "requests" as MetricUnit,
+ history: alert.history ?? null,
+ baseline: undefined,
+ observed: alert.lastUsedDate,
+ dates: [
+ [
+ i18n.t(alert.model.status === "deprecated" ? "alerts.unified.retired" : "alerts.unified.retires"),
+ alert.model.deprecation_date,
+ ],
+ [i18n.t("alerts.unified.lastUsed"), alert.lastUsedDate],
+ ],
+ chartTitle: i18n.t("alerts.usageChart.title"),
+ };
+ }
+ const alert = item.alert;
+ const name =
+ alert.resource_name ||
+ (alert.resource_type === "key"
+ ? i18n.t("alerts.unified.keyFallback", { id: alert.resource_id.slice(0, 8) })
+ : alert.resource_id);
+ if (!("value" in alert.evidence)) {
+ return {
+ id: alert.id,
+ title: i18n.t("alerts.unified.retiresTitle", { name, date: dateLabel(alert.evidence.deprecation_date) }),
+ description: i18n.t("alerts.unified.deprecation"),
+ status: alert.status,
+ resourceType: alert.resource_type,
+ resourceName: name,
+ href: modelGroupHref(alert.resource_id),
+ linkLabel: i18n.t("alerts.unified.links.model"),
+ unit: "requests" as MetricUnit,
+ history: null,
+ baseline: undefined,
+ observed: alert.window_start.slice(0, 10),
+ dates: [[i18n.t("alerts.unified.retires"), alert.evidence.deprecation_date]],
+ chartTitle: i18n.t("alerts.usageChart.title"),
+ };
+ }
+ const evidence = alert.evidence;
+ const observed = alert.window_start.slice(0, 10);
+ const label = i18n.t(`alerts.unified.metrics.${alert.type}`);
+ const direction = evidence.value < evidence.baseline ? "fell" : "rose";
+ return {
+ id: alert.id,
+ title:
+ evidence.baseline > 0
+ ? i18n.t(`alerts.unified.comparison.${direction}`, {
+ label,
+ name,
+ ratio: (evidence.value / evidence.baseline).toLocaleString(undefined, { maximumFractionDigits: 2 }),
+ })
+ : i18n.t(`alerts.unified.value.${direction}`, {
+ label,
+ name,
+ value: metricValue(evidence.value, evidence.unit),
+ }),
+ description: i18n.t("alerts.unified.description", {
+ value: metricValue(evidence.value, evidence.unit),
+ date: dateLabel(observed),
+ baseline: metricValue(evidence.baseline, evidence.unit),
+ }),
+ status: alert.status,
+ resourceType: alert.resource_type,
+ resourceName: name,
+ href: activityLink(alert),
+ linkLabel: i18n.t(`alerts.unified.links.${alert.resource_type}`),
+ unit: evidence.unit,
+ history: alert.history,
+ baseline: evidence.baseline,
+ observed,
+ dates: [
+ [i18n.t("alerts.unified.observed"), observed],
+ [i18n.t("alerts.unified.firstDetected"), alert.created_at],
+ ],
+ chartTitle: i18n.t(`alerts.unified.chart.titles.${evidence.unit}`),
+ };
+}
+export type AlertPresentation = ReturnType;
diff --git a/ui/litellm-dashboard/src/components/Alerts/useAlerts.ts b/ui/litellm-dashboard/src/components/Alerts/useAlerts.ts
new file mode 100644
index 00000000000..70813ef2e93
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Alerts/useAlerts.ts
@@ -0,0 +1,44 @@
+import type { components } from "@/lib/http/schema";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { backgroundFetchClient } from "@/lib/http/api";
+import { getProxyBaseUrl } from "@/components/networking";
+import { AlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
+import type { AlertFilter } from "./alertPresentation";
+
+export function useAlerts(scope: string, enabled: boolean, status: AlertStatus, filter: AlertFilter) {
+ const options = {
+ queryKey: ["alerts", getProxyBaseUrl(), scope, status, filter],
+ enabled: enabled && filter !== "model-deprecations",
+ initialPageParam: undefined as string | undefined,
+ queryFn: async ({ pageParam, signal }: { pageParam: string | undefined; signal: AbortSignal }) => {
+ const type = filter === "all" || filter === "model-deprecations" ? "performance" : filter;
+ const { data } = await backgroundFetchClient.GET("/alerts", {
+ params: { query: { status, type, limit: 25, cursor: pageParam } },
+ signal,
+ });
+ if (!data) throw new Error("Could not load alerts");
+ return data;
+ },
+ getNextPageParam: (page: components["schemas"]["AlertViewPage"]) => page.next_cursor ?? undefined,
+ staleTime: 60_000,
+ retry: false,
+ };
+ return useInfiniteQuery(options);
+}
+export function useAlert(id: string | null, scope: string, enabled: boolean) {
+ const options = {
+ queryKey: ["alert", getProxyBaseUrl(), scope, id],
+ enabled: enabled && id !== null,
+ queryFn: async ({ signal }: { signal: AbortSignal }) => {
+ const { data } = await backgroundFetchClient.GET("/alerts/{alert_id}", {
+ params: { path: { alert_id: id! } },
+ signal,
+ });
+ if (!data) throw new Error("Could not load alert");
+ return data;
+ },
+ staleTime: 60_000,
+ retry: false,
+ };
+ return useQuery(options);
+}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
index 993277dc972..61fcfc308ad 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
@@ -12,12 +12,48 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
+let role = "proxy_admin";
+const alertRequests: string[] = [];
+const performanceAlert = {
+ id: "9007199254740993",
+ type: "spend",
+ resource_type: "team",
+ resource_id: "example-team",
+ resource_name: "Research",
+ status: "open",
+ window_start: "2026-11-10T00:00:00Z",
+ window_end: "2026-11-11T00:00:00Z",
+ created_at: "2026-11-11T03:00:00Z",
+ updated_at: "2026-11-11T03:00:00Z",
+ evidence: { value: 1200, baseline: 200, unit: "usd", sample_size: 1000 },
+};
+let openAlerts = [performanceAlert];
+let rejectAlerts = false;
+let rejectDeprecations = false;
+
beforeEach(() => {
+ role = "proxy_admin";
+ alertRequests.length = 0;
+ openAlerts = [performanceAlert];
+ rejectAlerts = false;
+ rejectDeprecations = false;
localStorage.clear();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
- const url = String(input);
+ const url = input instanceof Request ? input.url : String(input);
+ if (url.endsWith("/management/v1/users/me")) {
+ return Response.json({ user_id: "example-admin", user_role: role });
+ }
+ if (url.endsWith("/model/deprecations?warn_within_days=30")) {
+ if (rejectDeprecations) return Response.json({ detail: "Unavailable" }, { status: 503 });
+ return Response.json({ deprecated: [], imminent: [] });
+ }
+ if (url.includes("/alerts?")) {
+ alertRequests.push(url);
+ if (rejectAlerts) return Response.json({ detail: "Unavailable" }, { status: 503 });
+ return Response.json({ data: openAlerts, next_cursor: "next-page" });
+ }
if (url.endsWith("/get/ui_settings")) return Response.json({ values: {} });
if (url.endsWith("/litellm/.well-known/litellm-ui-config")) {
const config = {
@@ -56,19 +92,19 @@ afterEach(() => {
localStorage.clear();
});
-function renderHeader() {
+function renderHeader(accessToken: string | null = null) {
const user = userEvent.setup();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
-
+
,
);
- return { user };
+ return { user, client };
}
describe("dashboard header controls", () => {
@@ -84,7 +120,7 @@ describe("dashboard header controls", () => {
it("offers docs, community and latest posts under the bell", async () => {
const { user } = renderHeader();
await user.click(screen.getByRole("button", { name: /Notifications/ }));
- const tools = await screen.findByRole("dialog", { name: "What's new" });
+ const tools = await screen.findByRole("dialog", { name: "Notifications" });
expect(await within(tools).findByRole("link", { name: /Gateway update/ })).toHaveAttribute(
"href",
"https://example.com/update",
@@ -102,7 +138,7 @@ describe("dashboard header controls", () => {
const { user } = renderHeader();
expect(screen.queryByRole("link", { name: "Join Slack" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Notifications/ }));
- const tools = await screen.findByRole("dialog", { name: "What's new" });
+ const tools = await screen.findByRole("dialog", { name: "Notifications" });
expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toBeVisible();
expect(within(tools).queryByRole("link", { name: "Blog", exact: true })).not.toBeInTheDocument();
expect(within(tools).queryByRole("link", { name: "Discord", exact: true })).not.toBeInTheDocument();
@@ -118,3 +154,66 @@ describe("dashboard header controls", () => {
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
});
});
+
+describe("header performance notifications", () => {
+ it.each(["proxy_admin", "proxy_admin_viewer"])(
+ "loads open alerts for %s and refreshes after resolution",
+ async (userRole) => {
+ role = userRole;
+ const { user, client } = renderHeader("example-session");
+ await user.click(await screen.findByRole("button", { name: "Notifications, alerts need attention" }));
+ expect(screen.getByRole("link", { name: /Spend on Research/ })).toHaveAttribute(
+ "href",
+ `/ui/alerts?alert=${performanceAlert.id}`,
+ );
+ expect(alertRequests).toHaveLength(1);
+ const query = new URL(alertRequests[0]).searchParams;
+ expect(query.get("status")).toBe("open");
+ expect(query.get("type")).toBe("performance");
+ openAlerts = [];
+ await client.invalidateQueries({ queryKey: ["alerts"] });
+ expect(await screen.findByText("No open alerts.")).toBeVisible();
+ expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
+ client.clear();
+ },
+ );
+
+ it("hides cached performance alerts when refreshing fails", async () => {
+ const { user, client } = renderHeader("example-session");
+ await user.click(await screen.findByRole("button", { name: "Notifications, alerts need attention" }));
+ expect(screen.getByRole("link", { name: /Spend on Research/ })).toHaveTextContent("$1,200.00 vs. $200.00 usual");
+ rejectAlerts = true;
+ await client.invalidateQueries({ queryKey: ["alerts"] });
+ expect(await screen.findByText("Could not load performance alerts.")).toBeVisible();
+ expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
+ expect(screen.queryByText("No open alerts.")).not.toBeInTheDocument();
+ client.clear();
+ });
+
+ it.each([false, true])("reports deprecation failures with performance failure %s", async (performanceFails) => {
+ openAlerts = [];
+ rejectAlerts = performanceFails;
+ const { user, client } = renderHeader("example-session");
+ await user.click(screen.getByRole("button", { name: /Notifications/ }));
+ if (performanceFails) {
+ expect(await screen.findByText("Could not load performance alerts.")).toBeVisible();
+ } else {
+ expect(await screen.findByText("No open alerts.")).toBeVisible();
+ }
+ rejectDeprecations = true;
+ await client.invalidateQueries({ queryKey: ["deprecationAlerts"] });
+ expect(await screen.findByText("Could not load model deprecations.")).toBeVisible();
+ expect(screen.queryByText("No open alerts.")).not.toBeInTheDocument();
+ client.clear();
+ });
+
+ it.each(["internal_user", "org_admin"])("does not request performance alerts for %s", async (userRole) => {
+ role = userRole;
+ const { user, client } = renderHeader("example-session");
+ await user.click(screen.getByRole("button", { name: /Notifications/ }));
+ expect(await screen.findByRole("link", { name: /Gateway update/ })).toBeVisible();
+ expect(alertRequests).toHaveLength(0);
+ expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
+ client.clear();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/DeprecationAlertItem.tsx b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/DeprecationAlertItem.tsx
deleted file mode 100644
index bc7e8bc9ff3..00000000000
--- a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/DeprecationAlertItem.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { ArrowUpRight } from "lucide-react";
-import { useTranslation } from "react-i18next";
-import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
-import { buttonVariants } from "@/components/ui/button";
-import { cn } from "@/lib/cva.config";
-import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
-import RecommendedReplacement from "./RecommendedReplacement";
-import type { DeprecationAlert } from "./deprecationAlerts";
-
-export default function DeprecationAlertItem({ alert }: { alert: DeprecationAlert }) {
- const { t } = useTranslation();
- const retired = alert.model.status === "deprecated";
- const title = retired
- ? t("alerts.alertItem.deprecatedTitle", { model: alert.usageModel })
- : t("alerts.alertItem.retiresTitle", { model: alert.usageModel, days: alert.model.days_until_deprecation });
- return (
-
-
-
-
-
-
-
-
- {t("alerts.alertItem.usage", { requests: alert.requests.toLocaleString(), date: alert.lastUsedDate })}
-
-
-
-
-
- {t("alerts.alertItem.reviewMigration")}
-
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/ModelAlertsPage.integration.test.tsx b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/ModelAlertsPage.integration.test.tsx
index f8698556d49..4dd7fa21864 100644
--- a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/ModelAlertsPage.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/ModelAlertsPage.integration.test.tsx
@@ -1,7 +1,7 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
-import ModelAlertsPage from "@/app/(dashboard)/alerts/page";
+import ModelDeprecationsPanel from "./ModelDeprecationsPanel";
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({ data: [{ team_id: "support", team_alias: "Support" }] }),
@@ -69,19 +69,9 @@ describe("model migration impact", () => {
});
});
afterEach(() => vi.restoreAllMocks());
- it("should render the inbox with last seen and the recommendation supplied by the API", () => {
- renderWithProviders();
- expect(screen.getByRole("tab", { name: "Model deprecations", exact: true })).toBeInTheDocument();
- expect(screen.getByText("replacement-model")).toBeInTheDocument();
- expect(screen.getByText("2026-11-04")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Review impact →" })).toHaveAttribute(
- "href",
- "/ui/alerts?type=model-deprecations&model=openai%2Fgpt-4-turbo",
- );
- });
it("should page through all affected keys instead of showing a top-ten sample", async () => {
const user = userEvent.setup();
- renderWithProviders(, { searchParams: { model: "openai/gpt-4-turbo" } });
+ renderWithProviders(, { searchParams: { model: "openai/gpt-4-turbo" } });
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();
expect(screen.getByText(/Showing 1-50 of 120/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Go to next page", exact: true }));
@@ -97,7 +87,7 @@ describe("model migration impact", () => {
});
it("should resolve the whole model using its global latest-day snapshot even in a team-filtered view", async () => {
const user = userEvent.setup();
- renderWithProviders(, {
+ renderWithProviders(, {
searchParams: { model: "openai/gpt-4-turbo", team: "support" },
});
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.test.ts b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.test.ts
index f38eee7fccf..cf3d81fce9a 100644
--- a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.test.ts
+++ b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.test.ts
@@ -76,3 +76,14 @@ describe("deprecationUsageRange", () => {
});
});
});
+
+it("retains daily totals, explicit zeroes and gaps for the alert chart", () => {
+ const alerts = usedDeprecationAlerts(
+ [model],
+ [day("2026-11-04", 12), day("2026-11-04", 3), day("2026-11-02", 0), day("2026-11-01", 8, "example-other")],
+ );
+ expect(alerts[0].history).toEqual([
+ { date: "2026-11-02", value: 0 },
+ { date: "2026-11-04", value: 15 },
+ ]);
+});
diff --git a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.ts b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.ts
index d22d940ab16..67d2809d02a 100644
--- a/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.ts
+++ b/ui/litellm-dashboard/src/components/ModelDeprecationAlerts/deprecationAlerts.ts
@@ -10,6 +10,7 @@ export interface DeprecationAlert {
requests: number;
lastUsedDate: string;
lastDayRequests: number;
+ history?: { date: string; value: number }[];
state?: components["schemas"]["DeprecationAlertState"];
}
@@ -52,7 +53,23 @@ export function usedDeprecationAlerts(
}
}
if (requests > 0) {
- const alert = { id, model, usageModel, requests, lastUsedDate, lastDayRequests };
+ const history = Array.from(new Set(days.map((day) => day.date)))
+ .sort()
+ .flatMap((date) => {
+ const recorded = days.filter((day) => day.date === date && day.breakdown?.models?.[usageModel]);
+ return recorded.length
+ ? [
+ {
+ date,
+ value: recorded.reduce(
+ (sum, day) => sum + (day.breakdown?.models?.[usageModel]?.metrics.api_requests ?? 0),
+ 0,
+ ),
+ },
+ ]
+ : [];
+ });
+ const alert = { id, model, usageModel, requests, lastUsedDate, lastDayRequests, history };
alerts.push(alert);
}
}
diff --git a/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx
index a19eaab6b79..d1784e3bb80 100644
--- a/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx
@@ -120,7 +120,7 @@ export function DashboardResources({ children }: { children?: ReactNode }) {
))}
)}
-
+