Merge branch 'litellm_alerts_pr2_final_validation' of /Users/mfkhalil/Code/litellm-activity-before into litellm_alerts_reconcile_03

This commit is contained in:
moe-berri 2026-11-07 11:24:29 +08:00
commit 93e9c7c795
15 changed files with 447 additions and 55 deletions

View file

@ -110,6 +110,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/v1/rust_control_plane/",
# Alerting / email / IP allowlist
"/alerting/",
"/issues/",
"/email/",
"/add/allowed_ip",
"/delete/allowed_ip",
@ -153,6 +154,7 @@ BACKEND_EXACT_PATHS: frozenset[str] = frozenset(
{
"/",
"/routes",
"/issues",
"/lens",
"/openapi.json",
"/docs",

View file

@ -1,11 +1,11 @@
CREATE TABLE "LiteLLM_UsageAlertRule" (
CREATE TABLE IF NOT EXISTS "LiteLLM_UsageAlertRule" (
"id" TEXT PRIMARY KEY, "scope_type" TEXT NOT NULL, "scope_id" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true, "record" JSONB NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX "LiteLLM_UsageAlertRule_enabled_deleted_id_idx" ON "LiteLLM_UsageAlertRule" ("enabled", "deleted", "id");
CREATE INDEX "LiteLLM_UsageAlertRule_scope_type_scope_id_deleted_idx" ON "LiteLLM_UsageAlertRule" ("scope_type", "scope_id", "deleted");
CREATE TABLE "LiteLLM_UsageAlertIncident" (
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertRule_enabled_deleted_id_idx" ON "LiteLLM_UsageAlertRule" ("enabled", "deleted", "id");
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertRule_scope_type_scope_id_deleted_idx" ON "LiteLLM_UsageAlertRule" ("scope_type", "scope_id", "deleted");
CREATE TABLE IF NOT EXISTS "LiteLLM_UsageAlertIncident" (
"id" TEXT PRIMARY KEY, "rule_id" TEXT NOT NULL, "scope_key" TEXT NOT NULL,
"scope_type" TEXT NOT NULL, "scope_id" TEXT, "metric" TEXT NOT NULL,
"status" TEXT NOT NULL, "day" TEXT NOT NULL, "evaluation_day" TEXT NOT NULL,
@ -13,12 +13,12 @@ CREATE TABLE "LiteLLM_UsageAlertIncident" (
"notified_channels" TEXT[] DEFAULT ARRAY[]::TEXT[],
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "LiteLLM_UsageAlertIncident_rule_id_scope_key_evaluation_day_idx" ON "LiteLLM_UsageAlertIncident" ("rule_id", "scope_key", "evaluation_day");
CREATE INDEX "LiteLLM_UsageAlertIncident_scope_type_scope_id_day_idx" ON "LiteLLM_UsageAlertIncident" ("scope_type", "scope_id", "day");
CREATE INDEX "LiteLLM_UsageAlertIncident_status_day_idx" ON "LiteLLM_UsageAlertIncident" ("status", "day");
CREATE INDEX "LiteLLM_UsageAlertIncident_updated_at_idx" ON "LiteLLM_UsageAlertIncident" ("updated_at");
CREATE TABLE "LiteLLM_UsageAlertCheckpoint" (
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertIncident_rule_id_scope_key_evaluation_day_idx" ON "LiteLLM_UsageAlertIncident" ("rule_id", "scope_key", "evaluation_day");
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertIncident_scope_type_scope_id_day_idx" ON "LiteLLM_UsageAlertIncident" ("scope_type", "scope_id", "day");
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertIncident_status_day_idx" ON "LiteLLM_UsageAlertIncident" ("status", "day");
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertIncident_updated_at_idx" ON "LiteLLM_UsageAlertIncident" ("updated_at");
CREATE TABLE IF NOT EXISTS "LiteLLM_UsageAlertCheckpoint" (
"id" TEXT PRIMARY KEY, "completed" BOOLEAN NOT NULL DEFAULT false,
"owner" TEXT, "expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "LiteLLM_UsageAlertCheckpoint_expires_at_idx" ON "LiteLLM_UsageAlertCheckpoint" ("expires_at");
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertCheckpoint_expires_at_idx" ON "LiteLLM_UsageAlertCheckpoint" ("expires_at");

View file

@ -1,4 +1,4 @@
CREATE TABLE "LiteLLM_Issue" (
CREATE TABLE IF NOT EXISTS "LiteLLM_Issue" (
"id" TEXT NOT NULL,
"cohort_key" TEXT NOT NULL,
"kind" TEXT NOT NULL,
@ -14,19 +14,19 @@ CREATE TABLE "LiteLLM_Issue" (
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_Issue_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LiteLLM_Issue_status_last_seen_id_idx" ON "LiteLLM_Issue"("status", "last_seen", "id");
CREATE INDEX "LiteLLM_Issue_kind_family_last_seen_id_idx" ON "LiteLLM_Issue"("kind", "family", "last_seen", "id");
CREATE INDEX "LiteLLM_Issue_scope_type_scope_id_last_seen_id_idx" ON "LiteLLM_Issue"("scope_type", "scope_id", "last_seen", "id");
CREATE INDEX "LiteLLM_Issue_cohort_key_active_idx" ON "LiteLLM_Issue"("cohort_key", "active");
CREATE TABLE "LiteLLM_IssueSignal" (
CREATE INDEX IF NOT EXISTS "LiteLLM_Issue_status_last_seen_id_idx" ON "LiteLLM_Issue"("status", "last_seen", "id");
CREATE INDEX IF NOT EXISTS "LiteLLM_Issue_kind_family_last_seen_id_idx" ON "LiteLLM_Issue"("kind", "family", "last_seen", "id");
CREATE INDEX IF NOT EXISTS "LiteLLM_Issue_scope_type_scope_id_last_seen_id_idx" ON "LiteLLM_Issue"("scope_type", "scope_id", "last_seen", "id");
CREATE INDEX IF NOT EXISTS "LiteLLM_Issue_cohort_key_active_idx" ON "LiteLLM_Issue"("cohort_key", "active");
CREATE TABLE IF NOT EXISTS "LiteLLM_IssueSignal" (
"issue_id" TEXT NOT NULL,
"source" TEXT NOT NULL,
"source_id" TEXT NOT NULL,
"record" JSONB NOT NULL,
CONSTRAINT "LiteLLM_IssueSignal_pkey" PRIMARY KEY ("issue_id", "source", "source_id")
);
CREATE INDEX "LiteLLM_IssueSignal_source_source_id_idx" ON "LiteLLM_IssueSignal"("source", "source_id");
CREATE TABLE "LiteLLM_IssueDelivery" (
CREATE INDEX IF NOT EXISTS "LiteLLM_IssueSignal_source_source_id_idx" ON "LiteLLM_IssueSignal"("source", "source_id");
CREATE TABLE IF NOT EXISTS "LiteLLM_IssueDelivery" (
"id" TEXT NOT NULL,
"issue_id" TEXT NOT NULL,
"record" JSONB NOT NULL,
@ -35,15 +35,15 @@ CREATE TABLE "LiteLLM_IssueDelivery" (
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_IssueDelivery_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LiteLLM_IssueDelivery_sent_next_attempt_id_idx" ON "LiteLLM_IssueDelivery"("sent", "next_attempt", "id");
CREATE INDEX "LiteLLM_IssueDelivery_issue_id_idx" ON "LiteLLM_IssueDelivery"("issue_id");
CREATE TABLE "LiteLLM_IssueDeliveryRule" (
CREATE INDEX IF NOT EXISTS "LiteLLM_IssueDelivery_sent_next_attempt_id_idx" ON "LiteLLM_IssueDelivery"("sent", "next_attempt", "id");
CREATE INDEX IF NOT EXISTS "LiteLLM_IssueDelivery_issue_id_idx" ON "LiteLLM_IssueDelivery"("issue_id");
CREATE TABLE IF NOT EXISTS "LiteLLM_IssueDeliveryRule" (
"delivery_id" TEXT NOT NULL,
"rule_id" TEXT NOT NULL,
"source_id" TEXT NOT NULL,
"rule_revision" TEXT NOT NULL,
CONSTRAINT "LiteLLM_IssueDeliveryRule_pkey" PRIMARY KEY ("delivery_id", "rule_id", "source_id")
);
ALTER TABLE "LiteLLM_UsageAlertIncident" ADD COLUMN "evaluation_revision" TEXT NOT NULL DEFAULT '';
CREATE INDEX "LiteLLM_UsageAlertIncident_evaluation_day_breached_id_idx"
ALTER TABLE "LiteLLM_UsageAlertIncident" ADD COLUMN IF NOT EXISTS "evaluation_revision" TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS "LiteLLM_UsageAlertIncident_evaluation_day_breached_id_idx"
ON "LiteLLM_UsageAlertIncident"("evaluation_day", "breached", "id");

View file

@ -53,8 +53,11 @@ class IssueStore(Protocol):
status: UsageAlertStatus,
expected_status: UsageAlertStatus,
actor: str | None = None,
authorized_scopes: tuple[IssueCohort, ...] | None = None,
) -> Issue | None: ...
async def status_scopes(self, issue_id: str) -> tuple[IssueCohort, ...]: ...
async def upsert_issue(
self,
data: IssueInput,

View file

@ -25,6 +25,7 @@ from litellm.types.proxy.auth.user_roles import LitellmUserRoles
from litellm.types.proxy.issues import (
Issue,
IssueActivityResponse,
IssueCohort,
IssueFamily,
IssueFamilyList,
IssueFamilySetting,
@ -106,6 +107,29 @@ async def require_activity_access(issue: Issue, user: User, repository: IssuesRe
raise HTTPException(status_code=403, detail="Full team Activity permission is required for this investigation")
async def require_status_scopes(
issue: Issue, user: User, repository: IssueStore, alerts: UsageAlertsRepositoryDep
) -> tuple[IssueCohort, ...] | None:
store: Final = require_repository(alerts)
await require_scope(
user,
store,
"model" if issue.cohort.scope_type == "provider" else issue.cohort.scope_type,
issue.cohort.scope_id,
write=True,
)
if user.user_role == LitellmUserRoles.PROXY_ADMIN:
return None
scopes: Final = await repository.status_scopes(issue.id)
if len(scopes) > 10:
raise HTTPException(status_code=403, detail="An administrator must update this alert's status")
for scope in scopes:
await require_scope(
user, store, "model" if scope.scope_type == "provider" else scope.scope_type, scope.scope_id, write=True
)
return scopes
def activity_range(issue: Issue, start_date: date | None, end_date: date | None) -> tuple[date, date]:
start: Final = start_date or issue.range_start
end: Final = end_date or issue.range_end
@ -175,13 +199,7 @@ async def get_issue(
activity_allowed: Final = await can_view_activity(issue, user, require_issues(repository))
try:
require_write_access(user, f"/issues/{issue_id}")
await require_scope(
user,
require_repository(alerts),
"model" if issue.cohort.scope_type == "provider" else issue.cohort.scope_type,
issue.cohort.scope_id,
write=True,
)
await require_status_scopes(issue, user, require_issues(repository), alerts)
except HTTPException as exc:
if exc.status_code != 403:
raise
@ -198,11 +216,12 @@ async def update_issue(
alerts: UsageAlertsRepositoryDep,
) -> Issue:
require_write_access(user, f"/issues/{issue_id}")
issue: Final = await authorized_issue(issue_id, user, repository, alerts, write=True)
issue: Final = await authorized_issue(issue_id, user, repository, alerts)
scopes: Final = await require_status_scopes(issue, user, require_issues(repository), alerts)
if issue.kind == "model_deprecation" and not issue.active and data.status == "open":
raise HTTPException(status_code=409, detail="This retirement issue reopens when the model receives new traffic")
changed: Final = await require_issues(repository).set_status(
issue_id, data.status, data.expected_status, user.user_id
issue_id, data.status, data.expected_status, user.user_id, authorized_scopes=scopes
)
if changed is None:
raise HTTPException(status_code=409, detail="Issue status changed; reload and retry")

View file

@ -229,9 +229,7 @@ async def update_usage_alert_rule(
)
await require_scope(user, store, data.scope_type, data.scope_id, write=True)
require_notification_access(user, data, rule)
saved: Final = await store.save_rule(
data, user.user_id, existing=rule, overrides_defaults=user.user_role == LitellmUserRoles.PROXY_ADMIN
)
saved: Final = await store.save_rule(data, user.user_id, existing=rule, overrides_defaults=rule.overrides_defaults)
if saved is None:
raise HTTPException(status_code=409, detail="Alert rule changed while being updated. Refresh and try again")
return saved

View file

@ -132,6 +132,8 @@ async def update_family(
**({"enabled": data.enabled} if data.enabled is not None else {}),
**updates.get(row.record.direction, {}),
"updated_at": timestamp,
"evaluation_status": "pending",
"last_evaluated_day": None,
}
)
for row in rows

View file

@ -251,12 +251,20 @@ class IssuesRepository:
status: UsageAlertStatus,
expected_status: UsageAlertStatus,
actor: str | None = None,
authorized_scopes: tuple[IssueCohort, ...] | None = None,
) -> Issue | None:
if authorized_scopes is not None and len(authorized_scopes) > 10:
raise ValueError("Authorize at most 10 alert source scopes")
changed: Final = TypeAdapter(tuple[_Identifier, ...]).validate_python(
await self.database.query_raw(
f"""WITH candidate AS MATERIALIZED (
SELECT i.*,({_STATUS}) AS effective_status {_JOIN} WHERE i.id=$1
AND NOT(i.kind='model_deprecation' AND NOT i.active AND $2='open')
AND ($5::jsonb IS NULL OR NOT EXISTS (
SELECT 1 FROM "LiteLLM_IssueSignal" s WHERE s.issue_id=i.id AND NOT EXISTS (
SELECT 1 FROM jsonb_to_recordset($5::jsonb) AS x(scope_type text,scope_id text)
WHERE s.source='usage_alert' AND x.scope_type=s.record->'usage_alert'->>'scope_type'
AND x.scope_id IS NOT DISTINCT FROM s.record->'usage_alert'->>'scope_id')))
), ack AS (
INSERT INTO "LiteLLM_Config" AS prior (param_name,param_value)
SELECT ack_key,jsonb_build_object('model',record->'deprecation'->>'usage_model',
@ -269,18 +277,34 @@ class IssuesRepository:
WHERE EXISTS(SELECT 1 FROM candidate i WHERE i.ack_key=prior.param_name
AND ({_PRIOR_STATUS})=$3)
RETURNING param_name
) UPDATE "LiteLLM_Issue" i SET status=$2,updated_at=CURRENT_TIMESTAMP
FROM candidate c WHERE i.id=c.id AND c.effective_status=$3 AND
) UPDATE "LiteLLM_Issue" i SET status=$2,
updated_at=GREATEST(CURRENT_TIMESTAMP,i.updated_at+interval '1 millisecond')
FROM candidate c WHERE i.id=c.id AND (c.kind='model_deprecation' OR i.updated_at=c.updated_at)
AND c.effective_status=$3 AND
((c.kind<>'model_deprecation' AND i.status=$3) OR i.ack_key IN(SELECT param_name FROM ack))
RETURNING i.id""",
issue_id,
status,
expected_status,
actor,
json.dumps(tuple(scope.model_dump(mode="json") for scope in authorized_scopes))
if authorized_scopes is not None
else None,
)
)
return await self.get_issue(issue_id) if changed else None
async def status_scopes(self, issue_id: str) -> tuple[IssueCohort, ...]:
return TypeAdapter(tuple[IssueCohort, ...]).validate_python(
await self.database.query_raw(
"""SELECT DISTINCT CASE WHEN source='usage_alert' THEN record->'usage_alert'->>'scope_type'
ELSE 'deployment' END AS scope_type,
CASE WHEN source='usage_alert' THEN record->'usage_alert'->>'scope_id' END AS scope_id
FROM "LiteLLM_IssueSignal" WHERE issue_id=$1 ORDER BY scope_type,scope_id LIMIT 11""",
issue_id,
)
)
async def add_signals(self, issue_id: str, signals: tuple[IssueSignalInput, ...]) -> None:
await self.add_signal_batch(
tuple(IssueSignal.model_validate({**signal.model_dump(), "issue_id": issue_id}) for signal in signals)
@ -292,11 +316,30 @@ class IssuesRepository:
if len(signals) > 500:
raise ValueError("Write at most 500 issue signals per batch")
await self.database.query_raw(
"""INSERT INTO "LiteLLM_IssueSignal" (issue_id,source,source_id,record)
SELECT DISTINCT ON(x->>'issue_id',x->>'source',x->>'source_id')
x->>'issue_id',x->>'source',x->>'source_id',x FROM jsonb_array_elements($1::jsonb) x
WHERE EXISTS(SELECT 1 FROM "LiteLLM_Issue" i WHERE i.id=x->>'issue_id')
ON CONFLICT(issue_id,source,source_id) DO NOTHING RETURNING source_id""",
"""WITH incoming AS MATERIALIZED (
SELECT DISTINCT ON(x->>'issue_id',x->>'source',x->>'source_id') x AS record
FROM jsonb_array_elements($1::jsonb) x
), parents AS MATERIALIZED (
SELECT i.id FROM "LiteLLM_Issue" i WHERE i.id IN(SELECT record->>'issue_id' FROM incoming)
ORDER BY i.id FOR UPDATE
), added AS (
INSERT INTO "LiteLLM_IssueSignal" (issue_id,source,source_id,record)
SELECT x.record->>'issue_id',x.record->>'source',x.record->>'source_id',x.record
FROM incoming x JOIN parents p ON p.id=x.record->>'issue_id'
ON CONFLICT(issue_id,source,source_id) DO NOTHING RETURNING issue_id,source,record
) UPDATE "LiteLLM_Issue" i SET
updated_at=GREATEST(CURRENT_TIMESTAMP,i.updated_at+interval '1 millisecond'),
status=CASE WHEN i.kind='performance' AND i.active AND i.status<>'open' AND EXISTS (
SELECT 1 FROM added a WHERE a.issue_id=i.id AND a.source='usage_alert'
AND (a.record->'usage_alert'->>'scope_type'<>i.scope_type
OR a.record->'usage_alert'->>'scope_id' IS DISTINCT FROM i.scope_id)
AND NOT EXISTS(SELECT 1 FROM "LiteLLM_IssueSignal" prior
WHERE prior.issue_id=i.id AND prior.source='usage_alert'
AND prior.record->'usage_alert'->>'scope_type'=a.record->'usage_alert'->>'scope_type'
AND prior.record->'usage_alert'->>'scope_id'
IS NOT DISTINCT FROM a.record->'usage_alert'->>'scope_id')
) THEN 'open' ELSE i.status END
WHERE i.id IN(SELECT issue_id FROM added) RETURNING i.id""",
json.dumps(tuple(signal.model_dump(mode="json") for signal in signals)),
)
@ -423,14 +466,14 @@ class IssuesRepository:
COALESCE(r.enabled AND NOT r.deleted
AND r.scope_type=a.scope_type AND r.record->>'metric'=a.metric
AND r.record->>'direction'=a.record->>'direction'
AND (r.scope_id IS NULL OR r.scope_id=a.scope_id)
AND (NOT(r.id=ANY($2::text[])) OR NOT EXISTS (
AND (r.scope_id IS NULL OR r.scope_id=a.scope_id),false) AS eligible,
COALESCE(r.id=ANY($2::text[]) AND EXISTS (
SELECT 1 FROM "LiteLLM_UsageAlertRule" custom WHERE custom.enabled AND NOT custom.deleted
AND COALESCE((custom.record->>'overrides_defaults')::boolean,false)
AND NOT(custom.id=ANY($2::text[])) AND custom.scope_type=r.scope_type
AND custom.record->>'metric'=r.record->>'metric'
AND custom.record->>'direction'=r.record->>'direction'
AND (custom.scope_id IS NULL OR custom.scope_id=a.scope_id))),false) AS eligible
AND (custom.scope_id IS NULL OR custom.scope_id=a.scope_id)),false) AS suppressed
FROM "LiteLLM_Issue" i JOIN "LiteLLM_IssueSignal" s ON s.issue_id=i.id
LEFT JOIN "LiteLLM_UsageAlertIncident" a ON a.id=s.source_id
LEFT JOIN "LiteLLM_UsageAlertRule" r ON r.id=a.rule_id
@ -449,7 +492,7 @@ class IssuesRepository:
FROM primary_signals
), healthy AS (
SELECT id FROM observations GROUP BY id
HAVING bool_or(eligible AND healthy) AND bool_and(NOT eligible OR healthy)
HAVING bool_or(eligible AND healthy) AND bool_and(NOT eligible OR suppressed OR healthy)
), changed AS (
UPDATE "LiteLLM_Issue" i SET active=false,
status=CASE WHEN status='open' THEN 'resolved' ELSE status END,updated_at=CURRENT_TIMESTAMP
@ -602,6 +645,13 @@ class IssuesRepository:
AND a.scope_type=r.scope_type AND a.metric=r.record->>'metric'
AND a.record->>'direction'=r.record->>'direction'
AND (r.scope_id IS NULL OR a.scope_id=r.scope_id)
AND (NOT(r.id=ANY($3::text[])) OR NOT EXISTS (
SELECT 1 FROM "LiteLLM_UsageAlertRule" custom WHERE custom.enabled AND NOT custom.deleted
AND COALESCE((custom.record->>'overrides_defaults')::boolean,false)
AND NOT(custom.id=ANY($3::text[])) AND custom.scope_type=r.scope_type
AND custom.record->>'metric'=r.record->>'metric'
AND custom.record->>'direction'=r.record->>'direction'
AND (custom.scope_id IS NULL OR custom.scope_id=a.scope_id)))
AND EXISTS(SELECT 1 FROM "LiteLLM_IssueSignal" s WHERE s.issue_id=i.id
AND s.source='usage_alert' AND s.source_id=a.id)
ORDER BY b.rule_id,b.source_id LIMIT 1
@ -611,6 +661,7 @@ class IssuesRepository:
ORDER BY d.next_attempt,d.id LIMIT $1""",
limit,
delivery_id,
tuple(DEFAULT_USAGE_RULE_IDS),
)
)

View file

@ -683,7 +683,12 @@ class UsageAlertsRepository:
async def key_ownership(self, key_id: str) -> tuple[str | None, str | None] | None:
rows: Final = TypeAdapter(tuple[_Ownership, ...]).validate_python(
await self.database.query_raw(
'SELECT user_id,team_id FROM "LiteLLM_VerificationToken" WHERE token=$1', key_id
"""SELECT user_id,team_id FROM "LiteLLM_VerificationToken" WHERE token=$1
UNION ALL
(SELECT user_id,team_id FROM "LiteLLM_DeletedVerificationToken" WHERE token=$1
AND NOT EXISTS(SELECT 1 FROM "LiteLLM_VerificationToken" WHERE token=$1)
ORDER BY deleted_at DESC,id DESC LIMIT 1)""",
key_id,
)
)
return (rows[0].user_id, rows[0].team_id) if rows else None

View file

@ -1,7 +1,8 @@
from datetime import date, datetime
from typing import Literal, Self, TypeAlias
from typing import Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
from litellm.types.proxy.management_endpoints.common_daily_activity import (
BoundedDailyActivityResponse,

View file

@ -1,9 +1,10 @@
"""Daily usage alert rules and incidents shared by the API and evaluator."""
from datetime import date, datetime
from typing import Literal, Self, TypeAlias
from typing import Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
UsageAlertScope: TypeAlias = Literal["deployment", "key", "user", "team", "model"]
UsageAlertMetric: TypeAlias = Literal["spend", "requests", "latency", "errors", "error_rate"]

View file

@ -37,7 +37,7 @@ from litellm.types.proxy.issues import (
IssueSignalList,
)
from litellm.types.proxy.usage_alert_defaults import default_usage_rule_id, default_usage_rules
from litellm.types.proxy.usage_alerts import UsageAlertObservation, UsageAlertRuleInput
from litellm.types.proxy.usage_alerts import UsageAlert, UsageAlertObservation, UsageAlertRuleInput
def _issue(cohort: IssueCohort) -> IssueInput:
@ -157,7 +157,7 @@ async def test_issue_http_scope_pagination_and_source_visibility(gateway: Gatewa
path: Final = f"/issues/{first.id}"
own: Final = gateway.request("GET", path, key=owner_key)
assert own.status_code == 200 and own.json()["scope_label"] == "Example checkout"
assert own.json()["can_update_status"] is True
assert own.json()["can_update_status"] is False, "Broader source scopes require their own write permission"
assert own.json()["can_view_activity"] is True
assert gateway.request("GET", path, key=viewer_key).json()["can_update_status"] is False
assert gateway.request("GET", path, key=info_key).json()["can_update_status"] is False
@ -204,15 +204,18 @@ async def test_issue_http_scope_pagination_and_source_visibility(gateway: Gatewa
gateway.request(
"PATCH", path, {"status": "dismissed", "expected_status": "open"}, key=owner_key
).status_code
== 200
== 403
)
assert gateway.request("PATCH", path, {"status": "dismissed", "expected_status": "open"}).status_code == 200
assert gateway.request("PATCH", path, {"status": "resolved", "expected_status": "open"}).status_code == 409
assert gateway.request("GET", f"/issues/{second.id}", key=owner_key).json()["status"] == "open"
assert gateway.request("GET", f"/issues/{second.id}", key=owner_key).json()["can_update_status"] is True
assert (
gateway.request(
"PATCH", path, {"status": "resolved", "expected_status": "open"}, key=owner_key
"PATCH", f"/issues/{second.id}", {"status": "dismissed", "expected_status": "open"}, key=owner_key
).status_code
== 409
== 200
)
assert gateway.request("GET", f"/issues/{second.id}", key=owner_key).json()["status"] == "open"
finally:
await _remove_issues(db, (first.id, second.id, global_issue.id))
await alerts.delete_rule(rule.id)
@ -623,6 +626,91 @@ async def _family_database(db: ManagementDatabase) -> AsyncGenerator[Prisma]:
setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
@pytest.mark.asyncio(loop_scope="session")
async def test_late_default_override_stops_queued_delivery_and_recovers_on_fresh_evidence(
db: ManagementDatabase,
) -> None:
async with _family_database(db) as database:
borrowed: Final = cast(AlertDatabase, database) # cast-ok: real generated Prisma query delegate
for table in (
"LiteLLM_UsageAlertIncident",
"LiteLLM_Issue",
"LiteLLM_IssueSignal",
"LiteLLM_IssueDelivery",
"LiteLLM_IssueDeliveryRule",
"LiteLLM_VerificationToken",
"LiteLLM_TeamTable",
"LiteLLM_UserTable",
):
await borrowed.query_raw(f'CREATE TABLE "{table}" (LIKE public."{table}" INCLUDING ALL)')
alerts: Final = UsageAlertsRepository(borrowed)
store: Final = IssuesRepository(borrowed)
await alerts.ensure_default_rules(default_usage_rules(("slack",)))
rule: Final = await alerts.get_rule(default_usage_rule_id("key", "requests", "increase"))
assert rule is not None
issues: Final = tuple(_issue(IssueCohort(scope_type="key", scope_id=key)) for key in ("key-a", "key-b"))
observations: Final = tuple(
UsageAlertObservation(
entity_id=issue.cohort.scope_id,
day=issue.observed_on,
observed=420,
baseline=120,
threshold=360,
breached=True,
)
for issue in issues
)
sources: Final = await alerts.record_observations(rule, observations)
await store.upsert_issues(issues)
for issue in issues:
source: UsageAlert = next(source for source in sources if source.scope_id == issue.cohort.scope_id)
await store.add_signals(
issue.id, (IssueSignalInput(source="usage_alert", source_id=source.id, usage_alert=source),)
)
await store.queue_delivery(
IssueDeliveryInput(id=issue.id, issue_id=issue.id, channel="slack"),
rule.id,
rule.updated_at.isoformat(),
source.id,
)
assert await store.pending_delivery(issue.id) is not None
definition: Final = UsageAlertRuleInput(
name="Project A threshold",
scope_type="key",
scope_id="key-a",
metric="requests",
direction="increase",
multiplier=100,
)
ordinary: Final = await alerts.save_rule(definition, "example-user")
assert ordinary is not None and await store.pending_delivery(issues[0].id) is not None
assert await alerts.save_rule(definition, "qa-admin", ordinary, overrides_defaults=True) is not None
pending: Final = await store.pending_delivery(issues[0].id)
unaffected: Final = await store.pending_delivery(issues[1].id)
await store.recover_issues(issues[0].observed_on + timedelta(days=1), (issues[0].id,))
awaiting_evidence: Final = await store.get_issue(issues[0].id)
assert awaiting_evidence is not None and awaiting_evidence.active
await alerts.record_observations(
rule,
(
observations[0].model_copy(
update={"day": observations[0].day + timedelta(days=1), "observed": 120, "breached": False}
),
),
)
await store.recover_issues(issues[0].observed_on + timedelta(days=2), (issues[0].id,))
recovered: Final = await store.get_issue(issues[0].id)
other: Final = await store.get_issue(issues[1].id)
assert recovered is not None and other is not None
assert (pending is None, unaffected is not None, recovered.active, recovered.status, other.active) == (
True,
True,
False,
"resolved",
True,
), f"overridden delivery eligible={pending is not None}; recovered issue active={recovered.active}"
@pytest.mark.asyncio(loop_scope="session")
async def test_family_settings_preserve_custom_deleted_rules_and_abort_concurrent_edits(db: ManagementDatabase) -> None:
async with _family_database(db) as database:
@ -699,6 +787,189 @@ async def test_family_settings_preserve_custom_deleted_rules_and_abort_concurren
assert await alerts.get_rule(custom.id) == custom
@pytest.mark.asyncio(loop_scope="session")
async def test_family_edit_requires_a_new_evaluation(db: ManagementDatabase) -> None:
async with _family_database(db) as database:
borrowed: Final = cast(AlertDatabase, database) # cast-ok: real generated Prisma query delegate
alerts: Final = UsageAlertsRepository(borrowed)
store: Final = IssuesRepository(borrowed)
await alerts.ensure_default_rules(default_usage_rules())
rule: Final = await alerts.get_rule(default_usage_rule_id("key", "spend", "increase"))
assert rule is not None
await alerts.record_evaluation_status(rule, date.today() - timedelta(days=1), "complete")
result: Final = await store.update_family(
"spend", IssueFamilyUpdate(policies=(IssueFamilyPolicyUpdate(direction="increase", multiplier=5),))
)
saved: Final = await alerts.get_rule(rule.id)
assert result is not None and saved is not None
assert (result.evaluation_status_counts.complete, saved.evaluation_status, saved.last_evaluated_day) == (
0,
"pending",
None,
), "Changed family policy must reset stale evaluation status"
@pytest.mark.asyncio(loop_scope="session")
async def test_deleted_key_owner_retains_historical_issue_access(gateway: Gateway, db: ManagementDatabase) -> None:
store: Final = IssuesRepository.from_prisma(db.prisma)
with gateway.scenario() as scenario:
owner: Final = scenario.user(user_role="internal_user")
outsider: Final = scenario.user(user_role="internal_user")
owner_key: Final = scenario.key(user_id=owner, key_type="management")
outsider_key: Final = scenario.key(user_id=outsider, key_type="management")
reporting_id: Final = sha256(uuid4().bytes).hexdigest()
issue: Final = _issue(IssueCohort(scope_type="key", scope_id=reporting_id))
await _query(
db,
'INSERT INTO "LiteLLM_DeletedVerificationToken" (id,token,user_id,models) VALUES ($1,$1,$2,ARRAY[]::text[])',
reporting_id,
owner,
)
try:
await store.upsert_issue(issue)
path: Final = f"/issues/{issue.id}"
detail: Final = gateway.request("GET", path, key=owner_key)
assert detail.status_code == 200, "Deleted key owner must retain historical issue access"
assert detail.json()["can_update_status"] is True
assert gateway.request("GET", path, key=outsider_key).status_code == 403
assert (
gateway.request(
"PATCH", path, {"status": "dismissed", "expected_status": "open"}, key=owner_key
).status_code
== 200
)
await _query(
db,
'INSERT INTO "LiteLLM_VerificationToken" (token,user_id,models) VALUES ($1,$2,ARRAY[]::text[])',
reporting_id,
outsider,
)
assert gateway.request("GET", path, key=owner_key).status_code == 403
assert gateway.request("GET", path, key=outsider_key).status_code == 200
finally:
await _remove_issues(db, (issue.id,))
await _query(db, 'DELETE FROM "LiteLLM_VerificationToken" WHERE token=$1', reporting_id)
await _query(db, 'DELETE FROM "LiteLLM_DeletedVerificationToken" WHERE token=$1', reporting_id)
@pytest.mark.asyncio(loop_scope="session")
async def test_issue_status_rechecks_changed_scope_set_and_caps_sources(db: ManagementDatabase) -> None:
store: Final = IssuesRepository.from_prisma(db.prisma)
alerts: Final = UsageAlertsRepository.from_prisma(db.prisma)
issue: Final = _issue(IssueCohort(scope_type="user", scope_id=f"fixture-{uuid4()}"))
rule: Final = await alerts.save_rule(
UsageAlertRuleInput(
name="Scope guard",
scope_type="user",
scope_id=issue.cohort.scope_id,
metric="requests",
direction="increase",
),
"qa-admin",
)
assert rule is not None
try:
source: Final = (
await alerts.record_observations(
rule,
(
UsageAlertObservation(
entity_id=rule.scope_id,
day=issue.observed_on,
observed=420,
baseline=120,
threshold=360,
breached=True,
),
),
)
)[0]
await store.upsert_issue(issue)
await store.add_signals(
issue.id, (IssueSignalInput(source="usage_alert", source_id=source.id, usage_alert=source),)
)
authorized: Final = await store.status_scopes(issue.id)
assert authorized == (issue.cohort,)
assert await store.set_status(issue.id, "dismissed", "open", authorized_scopes=authorized) is not None
context: Final = source.model_copy(update={"id": str(uuid4()), "metric": "spend"})
same_scope: Final = (IssueSignalInput(source="usage_alert", source_id=context.id, usage_alert=context),)
await store.add_signals(issue.id, same_scope)
unchanged: Final = await store.get_issue(issue.id)
assert unchanged is not None and unchanged.status == "dismissed"
await store.add_signals(issue.id, same_scope)
assert await store.get_issue(issue.id) == unchanged
broader: Final = source.model_copy(update={"id": str(uuid4()), "scope_type": "deployment", "scope_id": None})
await store.add_signals(
issue.id, (IssueSignalInput(source="usage_alert", source_id=broader.id, usage_alert=broader),)
)
assert await store.set_status(issue.id, "dismissed", "open", authorized_scopes=authorized) is None
current: Final = await store.get_issue(issue.id)
assert current is not None and current.status == "open"
all_scopes: Final = await store.status_scopes(issue.id)
assert await store.set_status(issue.id, "dismissed", "open", authorized_scopes=all_scopes) is not None
broader_context: Final = broader.model_copy(update={"id": str(uuid4()), "metric": "spend"})
await store.add_signals(
issue.id,
(IssueSignalInput(source="usage_alert", source_id=broader_context.id, usage_alert=broader_context),),
)
repeated_scope: Final = await store.get_issue(issue.id)
assert repeated_scope is not None and repeated_scope.status == "dismissed"
assert await store.set_status(issue.id, "open", "dismissed") is not None
racing: Final = source.model_copy(update={"id": str(uuid4()), "scope_type": "team", "scope_id": "racing-team"})
async with asyncio.TaskGroup() as tasks:
async with db.prisma.writer_db.tx(timeout=timedelta(seconds=30)) as transaction:
await transaction.query_raw('SELECT id FROM "LiteLLM_Issue" WHERE id=$1 FOR UPDATE', issue.id)
pid: Final = (
TypeAdapter(tuple[_Pid, ...])
.validate_python(await transaction.query_raw("SELECT pg_backend_pid() AS pid"))[0]
.pid
)
update: Final = tasks.create_task(
store.set_status(issue.id, "dismissed", "open", authorized_scopes=all_scopes)
)
async with asyncio.timeout(10):
while (
not TypeAdapter(tuple[_Blocked, ...])
.validate_python(
await _query(
db,
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1::int=ANY(pg_blocking_pids(pid))) AS blocked",
pid,
)
)[0]
.blocked
):
await asyncio.sleep(0.01)
locked: Final = IssuesRepository(
cast(AlertDatabase, transaction) # cast-ok: generated Prisma transaction query delegate
)
await locked.add_signals(
issue.id, (IssueSignalInput(source="usage_alert", source_id=racing.id, usage_alert=racing),)
)
assert update.result() is None
raced: Final = await store.get_issue(issue.id)
assert raced is not None and raced.status == "open"
await store.add_signals(
issue.id,
tuple(
IssueSignalInput(
source="usage_alert",
source_id=f"scope-{index}",
usage_alert=source.model_copy(update={"id": f"scope-{index}", "scope_id": f"other-user-{index}"}),
)
for index in range(20)
),
)
limited: Final = await store.status_scopes(issue.id)
assert len(limited) == 11
with pytest.raises(ValueError, match="at most 10"):
await store.set_status(issue.id, "open", "dismissed", authorized_scopes=limited)
finally:
await _remove_issues(db, (issue.id,))
await alerts.delete_rule(rule.id)
await _query(db, 'DELETE FROM "LiteLLM_UsageAlertIncident" WHERE rule_id=$1', rule.id)
@pytest.mark.asyncio(loop_scope="session")
async def test_resource_recovery_ignores_broader_and_traffic_context(db: ManagementDatabase) -> None:
store: Final = IssuesRepository.from_prisma(db.prisma)

View file

@ -86,6 +86,29 @@ def test_scoped_rules_use_real_management_auth_and_preserve_read_only_roles(gate
assert gateway.request("GET", path, key=owner_key).status_code == 404
def test_admin_edit_preserves_owner_rule_access(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
owner: Final = scenario.user(user_role="internal_user")
owner_key: Final = scenario.key(user_id=owner, key_type="management")
config: Final = {
"name": "Owner policy",
"scope_type": "user",
"scope_id": owner,
"metric": "spend",
"direction": "increase",
}
created: Final = gateway.request("POST", "/usage/alert-rules", config, key=owner_key)
assert created.status_code == 201, created.text
path: Final = f"/usage/alert-rules/{created.json()['id']}"
try:
edited: Final = gateway.request("PUT", path, {**config, "name": "Renamed by admin"})
assert edited.status_code == 200, edited.text
owner_edit: Final = gateway.request("PUT", path, {**config, "multiplier": 5}, key=owner_key)
assert owner_edit.status_code == 200, "Admin edit must preserve the owner's rule access"
finally:
assert gateway.request("DELETE", path).status_code == 204
def test_team_admin_can_manage_team_alerts_but_member_can_only_read(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
admin_id: Final = scenario.user(user_role="internal_user")

View file

@ -421,6 +421,8 @@ def _user_spend_row(user_id: str, rows: Sequence[_Activity]) -> UserSpendRow:
api_requests=sum(row.api_requests for row in matching),
successful_requests=sum(row.successful_requests for row in matching),
failed_requests=sum(row.failed_requests for row in matching),
total_response_time_ms=sum(row.total_response_time_ms for row in matching),
timed_requests=sum(row.timed_requests for row in matching),
)
@ -445,6 +447,8 @@ def _key_spend_row(api_key: str, rows: Sequence[_Activity]) -> KeySpendRow:
api_requests=sum(row.api_requests for row in matching),
successful_requests=sum(row.successful_requests for row in matching),
failed_requests=sum(row.failed_requests for row in matching),
total_response_time_ms=sum(row.total_response_time_ms for row in matching),
timed_requests=sum(row.timed_requests for row in matching),
cache_read_input_tokens=sum(row.cache_read_input_tokens for row in matching),
cache_creation_input_tokens=sum(row.cache_creation_input_tokens for row in matching),
)
@ -856,6 +860,8 @@ def test_key_page_routes_map_ranked_rows_and_totals(
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
"total_response_time_ms": 100,
"timed_requests": 1,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 1,
},
@ -877,6 +883,8 @@ def test_key_page_routes_map_ranked_rows_and_totals(
"api_requests": 2,
"successful_requests": 2,
"failed_requests": 0,
"total_response_time_ms": 200,
"timed_requests": 2,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 2,
},
@ -991,6 +999,8 @@ def test_user_page_route_ranks_users_by_spend_and_attaches_metadata(
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
"total_response_time_ms": 100,
"timed_requests": 1,
},
{
"user_id": "user-a",
@ -1003,6 +1013,8 @@ def test_user_page_route_ranks_users_by_spend_and_attaches_metadata(
"api_requests": 6,
"successful_requests": 6,
"failed_requests": 0,
"total_response_time_ms": 600,
"timed_requests": 6,
},
],
"total_users": 2,
@ -1191,6 +1203,8 @@ def test_model_top_routes_rank_keys_and_include_metadata(
api_requests=1,
successful_requests=1,
failed_requests=0,
total_response_time_ms=100,
timed_requests=1,
cache_read_input_tokens=0,
cache_creation_input_tokens=1,
)
@ -1486,6 +1500,8 @@ def test_user_cache_leakage_route_returns_cache_keys_and_metadata(
api_requests=1,
successful_requests=1,
failed_requests=0,
total_response_time_ms=100,
timed_requests=1,
cache_read_input_tokens=20,
cache_creation_input_tokens=1,
)