From 59f5bc42d47be49e412756cc7e29b069c85727cf Mon Sep 17 00:00:00 2001 From: moe-berri Date: Mon, 9 Nov 2026 11:18:37 +0800 Subject: [PATCH] feat(alerts): support whole-gateway alert scope --- litellm/models/alert.py | 8 +++-- tests/integration/database/test_alerts.py | 31 +++++++++++++++++++ tests/unit/models/test_alert.py | 7 +++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +-- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/litellm/models/alert.py b/litellm/models/alert.py index 7db2ddf8d05..c9a26c6aff9 100644 --- a/litellm/models/alert.py +++ b/litellm/models/alert.py @@ -6,7 +6,7 @@ from typing_extensions import Self AlertType: TypeAlias = Literal["spend", "traffic", "latency", "reliability", "model_deprecation"] AlertStatus: TypeAlias = Literal["open", "resolved", "dismissed"] -AlertResourceType: TypeAlias = Literal["key", "team", "user", "model"] +AlertResourceType: TypeAlias = Literal["gateway", "key", "team", "user", "model"] MAX_ALERT_ID: Final = 2**63 - 1 @@ -45,7 +45,9 @@ class AlertDetection(BaseModel): type: AlertType = Field(description="Built-in alert category.") resource_type: AlertResourceType = Field(description="Kind of affected resource.") resource_id: str = Field( - min_length=1, max_length=1024, description="Resource ID. Keys use their SHA-256 hash, never a secret." + min_length=1, + max_length=1024, + description="Resource ID. Whole-gateway alerts use 'gateway'. Keys use their SHA-256 hash, never a secret.", ) window_start: AwareDatetime = Field( description="Inclusive start of the observed window, normalized to UTC milliseconds." @@ -64,6 +66,8 @@ class AlertDetection(BaseModel): def validate_detection(self) -> Self: if self.window_end <= self.window_start: raise ValueError("window_end must be after window_start") + if self.resource_type == "gateway" and self.resource_id != "gateway": + raise ValueError("Whole-gateway alerts must use resource_id 'gateway'") if self.resource_type == "key" and ( len(self.resource_id) != 64 or any(c not in "0123456789abcdef" for c in self.resource_id) ): diff --git a/tests/integration/database/test_alerts.py b/tests/integration/database/test_alerts.py index 4d468637291..b3054edba02 100644 --- a/tests/integration/database/test_alerts.py +++ b/tests/integration/database/test_alerts.py @@ -86,6 +86,37 @@ async def test_each_alert_type_round_trips_through_api_and_status_changes( assert await alerts.repository.get(int(saved.id)) == changed +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ("spend", "traffic", "latency", "reliability")) +async def test_gateway_alert_round_trips_without_colliding_with_resource_alerts( + alerts: Alerts, gateway: Gateway, kind: AlertType +) -> None: + detection: Final = AlertDetection.model_validate( + {**alerts.detection(kind).model_dump(), "resource_type": "gateway", "resource_id": "gateway"} + ) + try: + saved: Final = await alerts.repository.record(detection) + scoped: Final = await alerts.repository.record(detection.model_copy(update={"resource_type": "model"})) + 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 + 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) + 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) + assert dismissed.status == "dismissed" + assert await alerts.repository.record(detection) == dismissed + assert await alerts.repository.get(int(scoped.id)) == scoped + finally: + write_rows( + 'DELETE FROM "LiteLLM_Alert" WHERE resource_id=%s AND type=%s AND window_start=%s AND window_end=%s', + ("gateway", kind, detection.window_start, detection.window_end), + ) + + @pytest.mark.asyncio async def test_concurrent_detection_retries_preserve_dismissal_and_original_evidence(alerts: Alerts) -> None: detection: Final = alerts.detection() diff --git a/tests/unit/models/test_alert.py b/tests/unit/models/test_alert.py index a0294981ec5..cf915c530b8 100644 --- a/tests/unit/models/test_alert.py +++ b/tests/unit/models/test_alert.py @@ -23,6 +23,13 @@ DETECTION: Final = { {"resource_id": "x" * 1025}, {"resource_id": ""}, {"resource_type": "key", "resource_id": "sk-example-not-a-real-key"}, + {"resource_type": "gateway", "resource_id": "another-gateway"}, + { + "resource_type": "gateway", + "resource_id": "gateway", + "type": "model_deprecation", + "evidence": {"deprecation_date": "2026-02-01"}, + }, {"window_start": "2026-01-08T00:00:00"}, {"window_start": "2026-01-08T00:00:00.123456Z", "window_end": "2026-01-08T00:00:00.123457Z"}, {"window_end": "2026-01-08T00:00:00Z"}, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 380e4d07013..e926dbf2511 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27090,7 +27090,7 @@ export interface components { id: string; /** * Resource Id - * @description Resource ID. Keys use their SHA-256 hash, never a secret. + * @description Resource ID. Whole-gateway alerts use 'gateway'. Keys use their SHA-256 hash, never a secret. */ resource_id: string; /** @@ -27098,7 +27098,7 @@ export interface components { * @description Kind of affected resource. * @enum {string} */ - resource_type: "key" | "team" | "user" | "model"; + resource_type: "gateway" | "key" | "team" | "user" | "model"; /** * Status * @description Current review status.