feat(alerts): support whole-gateway alert scope

This commit is contained in:
moe-berri 2026-11-09 11:18:37 +08:00
parent 3609b9e494
commit 59f5bc42d4
4 changed files with 46 additions and 4 deletions

View file

@ -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)
):

View file

@ -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()

View file

@ -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"},

View file

@ -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.