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")} +
+ + + + + + + + + {data.map((point) => ( + + + + + ))} + +
{t("alerts.unified.chart.date")}{t("alerts.unified.chart.value")}
{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.title}

+
+ +
+
+

{alert.description}

+
+ +
+

{t("alerts.unified.resourceHeading")}

+
    +
  • +
    + + {alert.resourceName} +
    +
    + {alert.dates.map(([label, date]) => ( +
    +
    {label}
    +
    {dateLabel(date)} UTC
    +
    + ))} +
    + {alert.href && ( + + {alert.linkLabel} + + )} +
  • +
+
+ {item.source === "deprecation" && ( +
+ + + {t("alerts.unified.migrationDetails")} + +
+ )} +
+
+ ); +} 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 ( - - ); -} 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 }) { ))} )} -
+