From d1022dab913fcbaa44f3d02feaf5240e4dcda69c Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 23 Sep 2026 04:25:52 +0000 Subject: [PATCH] fix(proxy): ignore team member alert thresholds outside 1 to 100 on both the backend and the dashboard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 9 ++++--- .../proxy/auth/test_auth_checks.py | 26 +++++++++++++++++++ .../team/teamMemberBudgetAlertEmails.test.ts | 7 +++++ .../team/teamMemberBudgetAlertEmails.ts | 4 ++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 10e2d8fd73a..faccb613116 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -5539,14 +5539,17 @@ def _team_member_max_budget_alert_check( max_budget: float, ) -> None: raw_config: Final = (team_metadata or {}).get(TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY) - alert_email_config: Final = _merge_budget_alert_email_configs( + merged_config: Final = _merge_budget_alert_email_configs( global_cfg=None, per_key_cfg=raw_config if isinstance(raw_config, Mapping) else None, ) + alert_email_config: Final = { + pct: emails for pct, emails in (merged_config or {}).items() if pct.isdigit() and 1 <= int(pct) <= 100 + } if not alert_email_config or spend <= 0: return - min_pct: Final = min((int(k) for k in alert_email_config if k.isdigit()), default=None) - if min_pct is None or spend < max_budget * (min_pct / 100.0): + min_pct: Final = min(int(pct) for pct in alert_email_config) + if spend < max_budget * (min_pct / 100.0): return call_info: Final = CallInfo( spend=spend, diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 4608aecb9ec..184e96684ac 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3345,6 +3345,8 @@ async def test_virtual_key_max_budget_alert_check_without_user_obj(): (0.049, {"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@co.com"]}}, False), (0.0, {"team_member_max_budget_alert_emails": {"50": []}}, False), (0.10, {"team_member_max_budget_alert_emails": {"abc": []}}, False), + (0.05, {"team_member_max_budget_alert_emails": {"0": ["finance@co.com"], "100": []}}, False), + (0.10, {"team_member_max_budget_alert_emails": {"101": ["finance@co.com"]}}, False), (0.10, {"team_member_max_budget_alert_emails": "50"}, False), (0.10, {"soft_budget_alerting_emails": ["finance@co.com"]}, False), (0.10, None, False), @@ -3386,6 +3388,30 @@ async def test_team_member_max_budget_alert_check_dispatches_only_at_configured_ assert call_info.token is None +@pytest.mark.asyncio +async def test_team_member_max_budget_alert_check_drops_thresholds_outside_1_to_100(): + captured: list[CallInfo] = [] + + class RecordingProxyLogging: + async def budget_alerts(self, type, user_info): + captured.append(user_info) + + _team_member_max_budget_alert_check( + team_id="team-1", + team_alias="platform", + team_metadata={"team_member_max_budget_alert_emails": {"0": ["a@co.com"], "50": [], "150": ["b@co.com"]}}, + organization_id="org-1", + user_id="user-1", + user_email="member@co.com", + proxy_logging_obj=RecordingProxyLogging(), + spend=0.05, + max_budget=0.10, + ) + await asyncio.sleep(0) + + assert [call_info.max_budget_alert_emails for call_info in captured] == [{"50": []}], captured + + @pytest.mark.asyncio async def test_check_team_member_budget_dispatches_the_configured_alert_before_the_hard_cap(): from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TeamMembership diff --git a/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts b/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts index 1ecec588b6b..3faa051047b 100644 --- a/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts @@ -24,6 +24,13 @@ describe("teamMemberBudgetAlertRowsFromMetadata", () => { expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 100, emails: "a@b.c" }]); }); + it("drops API-stored thresholds outside 1 to 100 so they never block the form", () => { + const metadata = { + team_member_max_budget_alert_emails: { "0": ["a@b.c"], "50": [], "101": ["a@b.c"] }, + }; + expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 50, emails: "" }]); + }); + it.each([undefined, null, "50", { team_member_max_budget_alert_emails: "50" }, { soft_budget_alerting_emails: [] }])( "returns no rows for unrelated or malformed metadata %j", (metadata) => { diff --git a/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts b/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts index b4e473d3fc0..36d5ddbac02 100644 --- a/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts +++ b/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts @@ -35,7 +35,9 @@ export const teamMemberBudgetAlertRowsFromMetadata = (metadata: unknown): readon return Object.entries(config as Record) .flatMap(([key, emails]) => { const threshold = Number(key); - return /^\d+$/.test(key) && isEmailList(emails) ? [{ threshold, emails: emails.join(", ") }] : []; + return /^\d+$/.test(key) && isValidThreshold(threshold) && isEmailList(emails) + ? [{ threshold, emails: emails.join(", ") }] + : []; }) .sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0)); };