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>
This commit is contained in:
ryan 2026-09-23 04:25:52 +00:00
parent 2ce37c0b4e
commit d1022dab91
4 changed files with 42 additions and 4 deletions

View file

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

View file

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

View file

@ -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) => {

View file

@ -35,7 +35,9 @@ export const teamMemberBudgetAlertRowsFromMetadata = (metadata: unknown): readon
return Object.entries(config as Record<string, unknown>)
.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));
};