mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
fix(team): reject malformed team_member_max_budget_alert_emails on team writes
Thresholds outside 1-100, non-list recipients, and invalid emails now return 422 on
/team/new, /team/update and PATCH /team/{id} instead of being stored and silently
ignored. The value is stored canonically. Read-side LiteLLM_TeamTable is unchanged,
and the PATCH body stays a raw merge patch so a null threshold still deletes it.
This commit is contained in:
parent
8e4921895d
commit
703e754b46
4 changed files with 166 additions and 6 deletions
|
|
@ -7,14 +7,76 @@ budget-window value types and the team-model alias table). Re-exported from
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Final, Literal, Optional
|
||||
from typing import Annotated, Final, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY: Final = "team_member_max_budget_alert_emails"
|
||||
|
||||
|
||||
def _parse_team_member_budget_alert_threshold(raw: object) -> str:
|
||||
if isinstance(raw, str) and raw.isdigit() and len(raw) <= 3 and 1 <= int(raw) <= 100:
|
||||
return str(int(raw))
|
||||
raise ValueError(
|
||||
f"metadata.{TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY} thresholds must be whole-number percentages "
|
||||
f"from 1 to 100, got {raw!r}"
|
||||
)
|
||||
|
||||
|
||||
def _is_plausible_email(raw: object) -> bool:
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
local, at, domain = raw.strip().partition("@")
|
||||
return bool(local and at and domain) and not any(c.isspace() or c == "@" for c in local + domain)
|
||||
|
||||
|
||||
def _parse_team_member_budget_alert_recipients(threshold: str, raw: object) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(
|
||||
f"metadata.{TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY}[{threshold!r}] must be a list of email addresses"
|
||||
)
|
||||
invalid: Final = [email for email in raw if not _is_plausible_email(email)]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"metadata.{TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY}[{threshold!r}] has invalid email addresses: {invalid!r}"
|
||||
)
|
||||
return list(dict.fromkeys(email.strip() for email in raw))
|
||||
|
||||
|
||||
def validate_team_request_metadata(metadata: dict) -> dict:
|
||||
"""
|
||||
Reject a malformed team_member_max_budget_alert_emails on write and store it canonically,
|
||||
e.g. {"50": [], "100": ["finance@x.com"]}, so a bad threshold is a 422 instead of an alert that never fires.
|
||||
"""
|
||||
raw: Final = metadata.get(TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)
|
||||
if raw is None:
|
||||
return metadata
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError(
|
||||
f"metadata.{TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY} must map percentages to email lists, "
|
||||
'e.g. {"50": [], "100": ["finance@example.com"]}'
|
||||
)
|
||||
parsed: Final[dict[str, list[str]]] = {}
|
||||
for raw_threshold, raw_recipients in raw.items():
|
||||
threshold = _parse_team_member_budget_alert_threshold(raw_threshold)
|
||||
if threshold in parsed:
|
||||
raise ValueError(
|
||||
f"metadata.{TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY} lists the {threshold}% threshold more than once"
|
||||
)
|
||||
parsed[threshold] = _parse_team_member_budget_alert_recipients(threshold, raw_recipients)
|
||||
return {**metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY: parsed}
|
||||
|
||||
|
||||
# Request-side only: LiteLLM_TeamTable keeps plain `dict` so reading a stored row never fails validation.
|
||||
TeamRequestMetadata = Annotated[dict, AfterValidator(validate_team_request_metadata)]
|
||||
|
||||
|
||||
class MemberBase(LiteLLMPydanticObjectBase):
|
||||
user_id: str | None = Field(
|
||||
|
|
|
|||
|
|
@ -2075,6 +2075,7 @@ class OrgMember(MemberBase):
|
|||
|
||||
|
||||
from litellm.models.team import TeamBase as TeamBase # noqa: E402
|
||||
from litellm.models.team import TeamRequestMetadata as TeamRequestMetadata # noqa: E402
|
||||
|
||||
RouterSettingsDict = Annotated[
|
||||
dict[str, object],
|
||||
|
|
@ -2083,6 +2084,7 @@ RouterSettingsDict = Annotated[
|
|||
|
||||
|
||||
class NewTeamRequest(TeamBase):
|
||||
metadata: TeamRequestMetadata | None = None
|
||||
router_settings: RouterSettingsDict | None = None
|
||||
model_aliases: dict | None = None
|
||||
model_max_budget: GenericBudgetConfigType | None = Field(
|
||||
|
|
@ -2158,7 +2160,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
|||
team_id: str # required
|
||||
team_alias: str | None = None
|
||||
organization_id: str | None = None
|
||||
metadata: dict | None = None
|
||||
metadata: TeamRequestMetadata | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
tpd_limit: int | None = None
|
||||
|
|
@ -2211,6 +2213,9 @@ class PatchTeamRequest(UpdateTeamRequest):
|
|||
"""
|
||||
|
||||
team_id: str | None = None
|
||||
# A merge-patch body, not final metadata: `{"50": null}` must survive to delete that threshold.
|
||||
# patch_team validates the merged result when it re-parses it as UpdateTeamRequest.
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase):
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from litellm.litellm_core_utils.dd_tracing import tracer
|
|||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
from litellm.models.project import LiteLLM_ProjectTable
|
||||
from litellm.models.team import TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY
|
||||
from litellm.proxy._types import (
|
||||
RBAC_ROLES,
|
||||
CallInfo,
|
||||
|
|
@ -5524,9 +5525,6 @@ async def _virtual_key_max_budget_alert_check(
|
|||
)
|
||||
|
||||
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY: Final = "team_member_max_budget_alert_emails"
|
||||
|
||||
|
||||
def _team_member_max_budget_alert_check(
|
||||
team_id: str,
|
||||
team_alias: str | None,
|
||||
|
|
|
|||
|
|
@ -12947,6 +12947,101 @@ def test_patch_team_request_makes_team_id_optional():
|
|||
assert set(UpdateTeamRequest.model_fields).issubset(set(PatchTeamRequest.model_fields))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metadata.team_member_max_budget_alert_emails is validated on every team write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_cls_name", ["NewTeamRequest", "UpdateTeamRequest"])
|
||||
def test_team_member_budget_alert_emails_stored_canonically(request_cls_name):
|
||||
import litellm.proxy._types as proxy_types
|
||||
|
||||
parsed = getattr(proxy_types, request_cls_name).model_validate(
|
||||
{
|
||||
"team_id": "t",
|
||||
"metadata": {
|
||||
"cost_center": "1",
|
||||
"team_member_max_budget_alert_emails": {
|
||||
"050": None,
|
||||
"100": [" finance@example.com", "finance@example.com"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert parsed.metadata == {
|
||||
"cost_center": "1",
|
||||
"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@example.com"]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_cls_name", ["NewTeamRequest", "UpdateTeamRequest"])
|
||||
@pytest.mark.parametrize(
|
||||
"alert_emails",
|
||||
[
|
||||
{"0": []},
|
||||
{"101": []},
|
||||
{"12.5": []},
|
||||
{"-5": []},
|
||||
{"fifty": []},
|
||||
{"50": [], "050": []},
|
||||
{"50": "finance@example.com"},
|
||||
{"50": ["not-an-email"]},
|
||||
{"50": ["a b@example.com"]},
|
||||
{"50": [123]},
|
||||
["50"],
|
||||
],
|
||||
)
|
||||
def test_team_member_budget_alert_emails_rejects_malformed(request_cls_name, alert_emails):
|
||||
from pydantic import ValidationError
|
||||
|
||||
import litellm.proxy._types as proxy_types
|
||||
|
||||
with pytest.raises(ValidationError, match="team_member_max_budget_alert_emails"):
|
||||
getattr(proxy_types, request_cls_name).model_validate(
|
||||
{"team_id": "t", "metadata": {"team_member_max_budget_alert_emails": alert_emails}}
|
||||
)
|
||||
|
||||
|
||||
def test_team_row_with_malformed_alert_emails_still_loads():
|
||||
"""Validation is write-side only; reading a stored team never fails on this key."""
|
||||
from litellm.proxy._types import LiteLLM_TeamTable
|
||||
|
||||
row = LiteLLM_TeamTable(team_id="t", metadata={"team_member_max_budget_alert_emails": {"0": "x"}})
|
||||
|
||||
assert row.metadata == {"team_member_max_budget_alert_emails": {"0": "x"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_null_deletes_one_alert_threshold():
|
||||
"""The PATCH body is a merge patch, so {"50": null} must delete that threshold rather than
|
||||
being coerced to an empty recipient list before the merge."""
|
||||
_, update_mock = await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@example.com"]}},
|
||||
raw_body={"metadata": {"team_member_max_budget_alert_emails": {"50": None}}},
|
||||
)
|
||||
|
||||
written_metadata = update_mock.call_args.kwargs["data"]["metadata"]
|
||||
if isinstance(written_metadata, str):
|
||||
written_metadata = json.loads(written_metadata)
|
||||
assert written_metadata["team_member_max_budget_alert_emails"] == {"100": ["finance@example.com"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_rejects_malformed_alert_threshold():
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"team_member_max_budget_alert_emails": {"50": []}},
|
||||
raw_body={"metadata": {"team_member_max_budget_alert_emails": {"150": []}}},
|
||||
)
|
||||
|
||||
assert "team_member_max_budget_alert_emails" in str(exc.value.message)
|
||||
|
||||
|
||||
def test_patch_team_route_publishes_its_request_body_schema():
|
||||
"""The dashboard's generated client types this call off the OpenAPI spec, which
|
||||
FastAPI can only emit because the body is a declared parameter."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue