mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
feat(proxy): email alerts at configured percentages of a team member budget (#42665)
* feat(proxy): email alerts at configured percentages of a team member budget
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(alerting): label team member budget crossings as team member budget
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(auth): cover the team member alert dispatch from _check_team_member_budget
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(email): drop the emoji from the team member budget alert template
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* 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>
* fix(proxy): bound team member alert threshold key length before int parsing
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(integration): drop the legacy covers marker from the team member alert test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* 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.
* fix(auth): enforce and alert on team member budgets only in common_checks
The builder re-checked the team member budget inline before common_checks ran the same
check, so one request that crossed a team_member_max_budget_alert_emails threshold
dispatched two alerts. Drop the inline check; common_checks is the single authorization
point and already covers per-member rows, the team default member budget, zero-cost
skips and the cross-pod spend counter. Its 422 message now uses the TeamMember=user:team
form the builder and budget reservation already returned.
* Revert "fix(team): reject malformed team_member_max_budget_alert_emails on team writes"
This reverts commit 703e754b46.
* fix(alerting): keep BaseBudgetAlertType.get_event_message zero-arg
Requiring user_info broke existing callers and out-of-tree subclasses. The team member
label now comes from SlackAlerting.budget_alerts, so the interface and its Readme are
unchanged from main.
* fix(mcp): keep team member budget enforcement on the MCP OAuth auth dependency
The MCP OAuth dependency stops at _user_api_key_auth_builder and never reaches common_checks, so removing the builder's inline member budget check would have let over-budget members through there. Enforce it explicitly for that caller.
* fix(auth): keep main's team member budget enforcement, alert once per request
Restore the builder's team member budget check and 422 message exactly as on main and drop the MCP-only gate. The builder sends the member alert only on the request it rejects; common_checks sends it for requests that get past the builder, so no request alerts twice.
* test(integration): read team member alert deliveries without a shared accumulator
* test(integration): match team member alert deliveries by subject so other alerts cannot race the count
* refactor(proxy): build the team member alert threshold config without mutable collections
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(proxy): collapse the alert recipient isinstance checks into one call
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(integration): read the SMTP sink through lock-guarded snapshots and assert the exact deliveries
---------
Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c822c7fffa
commit
d08746feb1
17 changed files with 1113 additions and 18 deletions
|
|
@ -32,6 +32,7 @@ from litellm.integrations.email_templates.key_rotated_email import (
|
|||
from litellm.integrations.email_templates.templates import (
|
||||
MAX_BUDGET_ALERT_EMAIL_TEMPLATE,
|
||||
SOFT_BUDGET_ALERT_EMAIL_TEMPLATE,
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAIL_TEMPLATE,
|
||||
TEAM_SOFT_BUDGET_ALERT_EMAIL_TEMPLATE,
|
||||
)
|
||||
from litellm.integrations.email_templates.user_invitation_email import (
|
||||
|
|
@ -48,6 +49,12 @@ from litellm.secret_managers.main import get_secret_bool
|
|||
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
|
||||
|
||||
|
||||
def _max_budget_alert_id(user_info: CallInfo) -> str:
|
||||
if user_info.event_group == Litellm_EntityType.TEAM_MEMBER:
|
||||
return f"team_member:{user_info.user_id}:{user_info.team_id}"
|
||||
return user_info.token or user_info.user_id or "default_id"
|
||||
|
||||
|
||||
def _parse_email_list(raw) -> List[str]:
|
||||
"""Parse emails from a list or comma-separated string."""
|
||||
if isinstance(raw, list):
|
||||
|
|
@ -373,17 +380,31 @@ class BaseEmailLogger(CustomLogger):
|
|||
greeting = html.escape(
|
||||
event.user_email or event.key_alias or event.token or ""
|
||||
)
|
||||
email_html_content = MAX_BUDGET_ALERT_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
recipient_email=greeting,
|
||||
percentage=percentage,
|
||||
spend=spend_str,
|
||||
max_budget=max_budget_str,
|
||||
alert_threshold=alert_threshold_str,
|
||||
base_url=email_params.base_url,
|
||||
email_support_contact=email_params.support_contact,
|
||||
email_footer=email_params.signature,
|
||||
)
|
||||
if event.event_group == Litellm_EntityType.TEAM_MEMBER:
|
||||
email_html_content = TEAM_MEMBER_MAX_BUDGET_ALERT_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
member=html.escape(event.user_email or event.user_id or ""),
|
||||
team_alias=html.escape(event.team_alias or event.team_id or ""),
|
||||
percentage=percentage,
|
||||
spend=spend_str,
|
||||
max_budget=max_budget_str,
|
||||
alert_threshold=alert_threshold_str,
|
||||
base_url=email_params.base_url,
|
||||
email_support_contact=email_params.support_contact,
|
||||
email_footer=email_params.signature,
|
||||
)
|
||||
else:
|
||||
email_html_content = MAX_BUDGET_ALERT_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
recipient_email=greeting,
|
||||
percentage=percentage,
|
||||
spend=spend_str,
|
||||
max_budget=max_budget_str,
|
||||
alert_threshold=alert_threshold_str,
|
||||
base_url=email_params.base_url,
|
||||
email_support_contact=email_params.support_contact,
|
||||
email_footer=email_params.signature,
|
||||
)
|
||||
await self.send_email(
|
||||
from_email=self.DEFAULT_LITELLM_EMAIL,
|
||||
to_email=recipient_emails,
|
||||
|
|
@ -607,7 +628,7 @@ class BaseEmailLogger(CustomLogger):
|
|||
if user_info.spend < threshold_amount:
|
||||
continue
|
||||
|
||||
_id = user_info.token or user_info.user_id or "default_id"
|
||||
_id = _max_budget_alert_id(user_info)
|
||||
_cache_key = (
|
||||
f"email_budget_alerts:max_budget_alert:{threshold_pct}:{_id}"
|
||||
)
|
||||
|
|
@ -618,7 +639,7 @@ class BaseEmailLogger(CustomLogger):
|
|||
emails.append(user_info.user_email)
|
||||
if not emails:
|
||||
verbose_proxy_logger.warning(
|
||||
"No recipients for %d%% threshold on key %s, skipping alert",
|
||||
"No recipients for %d%% threshold on %s, skipping alert",
|
||||
threshold_pct,
|
||||
_id,
|
||||
)
|
||||
|
|
@ -633,7 +654,11 @@ class BaseEmailLogger(CustomLogger):
|
|||
if send_count is not None and send_count > 1:
|
||||
continue
|
||||
|
||||
event_message = f"Max Budget Alert - {threshold_pct}% of Maximum Budget Reached"
|
||||
event_message = (
|
||||
f"Team Member Budget Alert - {threshold_pct}% of Team Member Budget Reached"
|
||||
if user_info.event_group == Litellm_EntityType.TEAM_MEMBER
|
||||
else f"Max Budget Alert - {threshold_pct}% of Maximum Budget Reached"
|
||||
)
|
||||
webhook_event = WebhookEvent(
|
||||
event="max_budget_alert",
|
||||
event_message=event_message,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ class TokenBudgetAlert(BaseBudgetAlertType):
|
|||
return "Key Budget: "
|
||||
|
||||
def get_id(self, user_info: CallInfo) -> str:
|
||||
if user_info.event_group == Litellm_EntityType.TEAM_MEMBER:
|
||||
return f"team_member:{user_info.user_id}:{user_info.team_id}"
|
||||
return user_info.token or "default_id"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -555,7 +555,11 @@ class SlackAlerting(CustomBatchLogger):
|
|||
budget_alert_class: Final = get_budget_alert_type(type)
|
||||
_id: Final = budget_alert_class.get_id(user_info)
|
||||
user_info_str: Final = self._get_user_info_str(user_info)
|
||||
event_message = budget_alert_class.get_event_message()
|
||||
event_message = (
|
||||
"Team Member Budget: "
|
||||
if user_info.event_group == Litellm_EntityType.TEAM_MEMBER
|
||||
else budget_alert_class.get_event_message()
|
||||
)
|
||||
|
||||
# Set default event unless we're in projected_limit_exceeded
|
||||
event: (
|
||||
|
|
|
|||
|
|
@ -131,3 +131,25 @@ MAX_BUDGET_ALERT_EMAIL_TEMPLATE: Final = """
|
|||
|
||||
{email_footer}
|
||||
"""
|
||||
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAIL_TEMPLATE: Final = """
|
||||
<img src="{email_logo_url}" alt="LiteLLM Logo" width="150" height="50" />
|
||||
|
||||
<p> Hi, <br/>
|
||||
|
||||
Team member <b>{member}</b> has reached <b>{percentage}% of their team member budget</b> in team <b>{team_alias}</b>. <br /> <br />
|
||||
|
||||
<b>Current Spend:</b> {spend} <br />
|
||||
<b>Team Member Budget:</b> {max_budget} <br />
|
||||
<b>Alert Threshold:</b> {alert_threshold} ({percentage}%) <br />
|
||||
|
||||
<p style="color: #dc2626; font-weight: 500;">
|
||||
Warning: Once this member reaches their team member budget of {max_budget}, their requests in this team will be rejected.
|
||||
</p>
|
||||
|
||||
You can view usage and manage team member budgets in the <a href="{base_url}">LiteLLM Dashboard</a>. <br /> <br />
|
||||
|
||||
If you have any questions, please send an email to {email_support_contact} <br /> <br />
|
||||
|
||||
{email_footer}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, Optional, Protocol, TypeAlias
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from typing_extensions import NotRequired, ReadOnly, Required, TypedDict, Unpack
|
||||
|
||||
import litellm
|
||||
|
|
@ -5682,6 +5682,64 @@ async def _virtual_key_max_budget_alert_check(
|
|||
)
|
||||
|
||||
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY: Final = "team_member_max_budget_alert_emails"
|
||||
_TEAM_MEMBER_ALERT_CONFIG_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def _is_valid_alert_threshold_pct(pct: str) -> bool:
|
||||
return pct.isdigit() and len(pct) <= 3 and 1 <= int(pct) <= 100
|
||||
|
||||
|
||||
def _alert_recipients(raw: object) -> Sequence[str] | None:
|
||||
if isinstance(raw, (str, Sequence)):
|
||||
return _parse_email_list(raw)
|
||||
return None
|
||||
|
||||
|
||||
def _valid_alert_threshold_config(raw_config: object) -> Mapping[str, str | Sequence[object] | None] | None:
|
||||
try:
|
||||
config: Final = _TEAM_MEMBER_ALERT_CONFIG_ADAPTER.validate_python(raw_config)
|
||||
except ValidationError:
|
||||
return None
|
||||
return MappingProxyType(
|
||||
{pct: _alert_recipients(emails) for pct, emails in config.items() if _is_valid_alert_threshold_pct(pct)}
|
||||
)
|
||||
|
||||
|
||||
def _team_member_max_budget_alert_check(
|
||||
team_id: str,
|
||||
team_alias: str | None,
|
||||
team_metadata: Mapping[str, object] | None,
|
||||
organization_id: str | None,
|
||||
user_id: str,
|
||||
user_email: str | None,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
spend: float,
|
||||
max_budget: float,
|
||||
) -> None:
|
||||
raw_config: Final = team_metadata.get(TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY) if team_metadata else None
|
||||
alert_email_config: Final = _merge_budget_alert_email_configs(
|
||||
global_cfg=None, per_key_cfg=_valid_alert_threshold_config(raw_config)
|
||||
)
|
||||
if not alert_email_config or spend <= 0:
|
||||
return
|
||||
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,
|
||||
max_budget=max_budget,
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
team_alias=team_alias,
|
||||
organization_id=organization_id,
|
||||
user_email=user_email,
|
||||
event_group=Litellm_EntityType.TEAM_MEMBER,
|
||||
max_budget_alert_emails=alert_email_config,
|
||||
)
|
||||
asyncio.create_task(proxy_logging_obj.budget_alerts(type="max_budget_alert", user_info=call_info))
|
||||
|
||||
|
||||
async def _check_team_member_budget(
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
user_object: LiteLLM_UserTable | None,
|
||||
|
|
@ -5747,7 +5805,22 @@ async def _check_team_member_budget(
|
|||
max_budget=team_member_budget,
|
||||
)
|
||||
|
||||
if math.isfinite(team_member_budget) and team_member_spend >= team_member_budget:
|
||||
if not math.isfinite(team_member_budget):
|
||||
return
|
||||
|
||||
_team_member_max_budget_alert_check(
|
||||
team_id=team_object.team_id,
|
||||
team_alias=team_object.team_alias,
|
||||
team_metadata=team_object.metadata,
|
||||
organization_id=team_object.organization_id,
|
||||
user_id=valid_token.user_id,
|
||||
user_email=user_object.user_email if user_object is not None else None,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
spend=team_member_spend,
|
||||
max_budget=team_member_budget,
|
||||
)
|
||||
|
||||
if team_member_spend >= team_member_budget:
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=team_member_spend,
|
||||
max_budget=team_member_budget,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.proxy.auth.auth_checks import (
|
|||
_get_user_role,
|
||||
_is_model_cost_zero,
|
||||
_is_user_proxy_admin,
|
||||
_team_member_max_budget_alert_check,
|
||||
_virtual_key_max_budget_alert_check,
|
||||
_virtual_key_max_budget_check,
|
||||
_virtual_key_soft_budget_check,
|
||||
|
|
@ -2287,6 +2288,19 @@ async def _user_api_key_auth_builder(
|
|||
max_budget=team_member_budget,
|
||||
)
|
||||
if team_member_spend >= team_member_budget:
|
||||
# common_checks sends this alert on requests that get past here, so only the
|
||||
# request rejected here sends it from the builder.
|
||||
_team_member_max_budget_alert_check(
|
||||
team_id=_team_id,
|
||||
team_alias=valid_token.team_alias,
|
||||
team_metadata=valid_token.team_metadata,
|
||||
organization_id=valid_token.org_id,
|
||||
user_id=_user_id,
|
||||
user_email=user_obj.user_email if user_obj is not None else None,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
spend=team_member_spend,
|
||||
max_budget=team_member_budget,
|
||||
)
|
||||
_entity_id: Final = f"{valid_token.user_id}:{valid_token.team_id}"
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=team_member_spend,
|
||||
|
|
|
|||
127
tests/integration/_support/mail.py
Normal file
127
tests/integration/_support/mail.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socketserver
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from email import message_from_bytes
|
||||
from email.message import Message
|
||||
from queue import SimpleQueue
|
||||
from typing import Final
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Delivery:
|
||||
sender: str
|
||||
recipients: tuple[str, ...]
|
||||
message: Message
|
||||
|
||||
@property
|
||||
def subject(self) -> str:
|
||||
return str(self.message["Subject"])
|
||||
|
||||
@property
|
||||
def html(self) -> str:
|
||||
for part in self.message.walk():
|
||||
if part.get_content_type() == "text/html":
|
||||
return part.get_payload(decode=True).decode()
|
||||
return ""
|
||||
|
||||
|
||||
class Mailbox:
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host: Final = host
|
||||
self.port: Final = port
|
||||
self._lock: Final = threading.Lock()
|
||||
self._deliveries: tuple[Delivery, ...] = ()
|
||||
|
||||
def record(self, delivery: Delivery) -> None:
|
||||
with self._lock:
|
||||
self._deliveries = (*self._deliveries, delivery)
|
||||
|
||||
def deliveries(self) -> tuple[Delivery, ...]:
|
||||
with self._lock:
|
||||
return self._deliveries
|
||||
|
||||
|
||||
def _address(argument: str) -> str:
|
||||
return argument.split(":", 1)[1].strip().strip("<>")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def smtp_sink() -> Generator[Mailbox, None, None]:
|
||||
"""Owned plaintext SMTP peer; deliveries traverse the proxy's real smtplib client."""
|
||||
errors: Final[SimpleQueue[Exception]] = SimpleQueue()
|
||||
|
||||
class Handler(socketserver.StreamRequestHandler):
|
||||
timeout = 5
|
||||
|
||||
def handle(self) -> None:
|
||||
try:
|
||||
self._session()
|
||||
except Exception as error:
|
||||
errors.put(error)
|
||||
|
||||
def _reply(self, line: str) -> None:
|
||||
self.wfile.write(f"{line}\r\n".encode())
|
||||
self.wfile.flush()
|
||||
|
||||
def _session(self) -> None:
|
||||
self._reply("220 integration-smtp ready")
|
||||
# rebind-ok: the SMTP envelope is built across MAIL/RCPT lines and reset after DATA or RSET.
|
||||
sender = ""
|
||||
recipients: tuple[str, ...] = ()
|
||||
while True:
|
||||
raw: Final = self.rfile.readline()
|
||||
if not raw:
|
||||
return
|
||||
line: Final = raw.decode().rstrip("\r\n")
|
||||
verb: Final = line.split(" ", 1)[0].upper()
|
||||
if verb in {"EHLO", "HELO"}:
|
||||
self._reply("250 integration-smtp")
|
||||
elif verb == "MAIL":
|
||||
sender = _address(line)
|
||||
self._reply("250 OK")
|
||||
elif verb == "RCPT":
|
||||
recipients = (*recipients, _address(line))
|
||||
self._reply("250 OK")
|
||||
elif verb == "DATA":
|
||||
self._reply("354 End data with <CR><LF>.<CR><LF>")
|
||||
body = bytearray()
|
||||
while True:
|
||||
chunk: Final = self.rfile.readline()
|
||||
if not chunk or chunk == b".\r\n":
|
||||
break
|
||||
body.extend(chunk[1:] if chunk.startswith(b"..") else chunk)
|
||||
mailbox.record(Delivery(sender, recipients, message_from_bytes(bytes(body))))
|
||||
sender, recipients = "", ()
|
||||
self._reply("250 OK queued")
|
||||
elif verb == "RSET":
|
||||
sender, recipients = "", ()
|
||||
self._reply("250 OK")
|
||||
elif verb == "NOOP":
|
||||
self._reply("250 OK")
|
||||
elif verb == "QUIT":
|
||||
self._reply("221 Bye")
|
||||
return
|
||||
else:
|
||||
self._reply("502 Command not implemented")
|
||||
|
||||
class OwnedServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = False
|
||||
|
||||
with OwnedServer(("127.0.0.1", 0), Handler) as server:
|
||||
mailbox: Final = Mailbox("127.0.0.1", server.server_address[1])
|
||||
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
|
||||
thread.start()
|
||||
try:
|
||||
yield mailbox
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=6)
|
||||
assert not thread.is_alive(), "Owned SMTP server survived cleanup"
|
||||
server.server_close()
|
||||
failure: Final = None if errors.empty() else errors.get_nowait()
|
||||
assert failure is None, f"Owned SMTP peer failed: {failure!r}"
|
||||
93
tests/integration/spend/test_team_member_budget_alerts.py
Normal file
93
tests/integration/spend/test_team_member_budget_alerts.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.mail import smtp_sink
|
||||
from integration._support.process import owned_proxy
|
||||
|
||||
MEMBER_BUDGET: Final = 0.10
|
||||
CALL_COST: Final = 20 * 0.001 + 20 * 0.002
|
||||
|
||||
|
||||
def _membership_spend(user_id: str, team_id: str) -> float:
|
||||
rows: Final = read_rows(
|
||||
'SELECT spend FROM "LiteLLM_TeamMembership" WHERE user_id = %s AND team_id = %s', (user_id, team_id)
|
||||
)
|
||||
return float(str(rows[0]["spend"])) if rows else 0.0
|
||||
|
||||
|
||||
def test_team_member_budget_thresholds_email_member_and_configured_recipients(gateway: Gateway, tmp_path: Path) -> None:
|
||||
member_email: Final = f"member-{uuid.uuid4().hex}@integration.test"
|
||||
finance_email: Final = f"finance-{uuid.uuid4().hex}@integration.test"
|
||||
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
configuration["general_settings"]["alerting"] = ["email"]
|
||||
path: Final = tmp_path / "email-alerting.yaml"
|
||||
path.write_text(yaml.safe_dump(configuration))
|
||||
with smtp_sink() as mailbox:
|
||||
overrides: Final = {
|
||||
"SMTP_HOST": mailbox.host,
|
||||
"SMTP_PORT": str(mailbox.port),
|
||||
"SMTP_TLS": "False",
|
||||
"SMTP_SENDER_EMAIL": "alerts@integration.test",
|
||||
}
|
||||
with owned_proxy(gateway, tmp_path, overrides, config=path) as candidate, candidate.scenario() as scenario:
|
||||
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
|
||||
user_id: Final = scenario.user(user_email=member_email)
|
||||
team_id: Final = scenario.team(
|
||||
models=[model],
|
||||
team_member_budget=MEMBER_BUDGET,
|
||||
metadata={"team_member_max_budget_alert_emails": {"50": [], "100": [finance_email]}},
|
||||
)
|
||||
candidate.post("/team/member_add", {"team_id": team_id, "member": {"user_id": user_id, "role": "user"}})
|
||||
key: Final = scenario.key(team_id=team_id, user_id=user_id)
|
||||
|
||||
first: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": "first call"}]},
|
||||
key=key,
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
assert float(first.headers["x-litellm-response-cost"]) == pytest.approx(CALL_COST)
|
||||
eventually(
|
||||
lambda: _membership_spend(user_id, team_id), lambda spend: spend == pytest.approx(CALL_COST), seconds=70
|
||||
)
|
||||
assert mailbox.deliveries() == (), "no threshold is reached before the first call is recorded"
|
||||
|
||||
second: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": "second call"}]},
|
||||
key=key,
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
halfway: Final = eventually(mailbox.deliveries, lambda found: len(found) >= 1, seconds=30)
|
||||
assert [delivery.recipients for delivery in halfway] == [(member_email,)], halfway
|
||||
assert "50%" in halfway[0].subject, halfway[0].subject
|
||||
assert f"${MEMBER_BUDGET}" in halfway[0].html, halfway[0].html
|
||||
eventually(
|
||||
lambda: _membership_spend(user_id, team_id),
|
||||
lambda spend: spend == pytest.approx(2 * CALL_COST),
|
||||
seconds=70,
|
||||
)
|
||||
|
||||
third: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": "third call"}]},
|
||||
key=key,
|
||||
)
|
||||
assert third.status_code == 422 and third.json()["error"]["type"] == "budget_exceeded", third.text
|
||||
capped: Final = eventually(mailbox.deliveries, lambda found: len(found) >= 3, seconds=30)
|
||||
hundred: Final = capped[1:]
|
||||
assert all("100%" in delivery.subject for delivery in hundred), capped
|
||||
assert {recipient for delivery in hundred for recipient in delivery.recipients} == {
|
||||
member_email,
|
||||
finance_email,
|
||||
}, capped
|
||||
assert all(member_email in delivery.html and f"${MEMBER_BUDGET}" in delivery.html for delivery in hundred)
|
||||
assert len(capped) == 3, capped
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator, Mapping
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -52,6 +53,7 @@ from litellm.proxy.auth.auth_checks import (
|
|||
_log_budget_lookup_failure,
|
||||
_tag_max_budget_check,
|
||||
_team_max_budget_check,
|
||||
_team_member_max_budget_alert_check,
|
||||
_virtual_key_max_budget_alert_check,
|
||||
_check_agent_caller_model_access,
|
||||
_virtual_key_max_budget_check,
|
||||
|
|
@ -3774,6 +3776,141 @@ async def test_virtual_key_max_budget_alert_check_without_user_obj():
|
|||
assert captured_call_info.user_email is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spend, team_metadata, expect_alert",
|
||||
[
|
||||
(0.05, {"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@co.com"]}}, True),
|
||||
(0.10, {"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@co.com"]}}, True),
|
||||
(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),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_max_budget_alert_check_dispatches_only_at_configured_thresholds(
|
||||
spend, team_metadata, expect_alert
|
||||
):
|
||||
captured: list[tuple[str, CallInfo]] = []
|
||||
|
||||
class RecordingProxyLogging:
|
||||
async def budget_alerts(self, type, user_info):
|
||||
captured.append((type, user_info))
|
||||
|
||||
_team_member_max_budget_alert_check(
|
||||
team_id="team-1",
|
||||
team_alias="platform",
|
||||
team_metadata=team_metadata,
|
||||
organization_id="org-1",
|
||||
user_id="user-1",
|
||||
user_email="member@co.com",
|
||||
proxy_logging_obj=RecordingProxyLogging(),
|
||||
spend=spend,
|
||||
max_budget=0.10,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
if not expect_alert:
|
||||
assert captured == [], captured
|
||||
return
|
||||
assert [type for type, _ in captured] == ["max_budget_alert"], captured
|
||||
call_info = captured[0][1]
|
||||
assert call_info.event_group == Litellm_EntityType.TEAM_MEMBER
|
||||
assert (call_info.spend, call_info.max_budget) == (spend, 0.10)
|
||||
assert (call_info.user_id, call_info.user_email) == ("user-1", "member@co.com")
|
||||
assert (call_info.team_id, call_info.team_alias, call_info.organization_id) == ("team-1", "platform", "org-1")
|
||||
assert call_info.max_budget_alert_emails == {"50": [], "100": ["finance@co.com"]}
|
||||
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"],
|
||||
"1" * (sys.int_info.default_max_str_digits + 1): ["c@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
|
||||
|
||||
captured: list[tuple[str, CallInfo]] = []
|
||||
|
||||
class RecordingProxyLogging:
|
||||
async def budget_alerts(self, type, user_info):
|
||||
captured.append((type, user_info))
|
||||
|
||||
team_object = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
team_alias="platform",
|
||||
metadata={"team_member_max_budget_alert_emails": {"50": [], "100": ["finance@co.com"]}},
|
||||
)
|
||||
user_object = LiteLLM_UserTable(user_id="user-1", user_email="member@co.com")
|
||||
valid_token = UserAPIKeyAuth(token="tok-1", user_id="user-1", team_id="team-1")
|
||||
team_membership = LiteLLM_TeamMembership(
|
||||
user_id="user-1",
|
||||
team_id="team-1",
|
||||
spend=0.10,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=0.10),
|
||||
)
|
||||
|
||||
async def spend_from_fallback(counter_key, fallback_spend, max_budget=None, **kwargs):
|
||||
return fallback_spend
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.get_current_spend", spend_from_fallback),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_membership", new_callable=AsyncMock, return_value=team_membership
|
||||
),
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _check_team_member_budget(
|
||||
team_object=team_object,
|
||||
user_object=user_object,
|
||||
valid_token=valid_token,
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=RecordingProxyLogging(),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert (exc_info.value.entity_type, exc_info.value.entity_id) == ("team_member", "user-1:team-1")
|
||||
assert [type for type, _ in captured] == ["max_budget_alert"], captured
|
||||
call_info = captured[0][1]
|
||||
assert call_info.event_group == Litellm_EntityType.TEAM_MEMBER
|
||||
assert (call_info.spend, call_info.max_budget) == (0.10, 0.10)
|
||||
assert (call_info.user_id, call_info.user_email, call_info.team_id) == ("user-1", "member@co.com", "team-1")
|
||||
assert call_info.max_budget_alert_emails == {"50": [], "100": ["finance@co.com"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spend, max_budget, expect_alert",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_OrganizationTable,
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
LiteLLM_UserTable,
|
||||
Litellm_EntityType,
|
||||
LitellmUserRoles,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
|
|
@ -8055,6 +8056,152 @@ async def test_cached_key_team_member_budget_honours_temp_increase(expiry_offset
|
|||
assert "Max budget: 2.0" in exc_info.value.message
|
||||
|
||||
|
||||
async def _authenticate_and_authorize(mock_request, api_key):
|
||||
"""Builder then the single common_checks gate, the same sequence user_api_key_auth runs."""
|
||||
from litellm.proxy.auth.user_api_key_auth import _authorize_authenticated_request
|
||||
|
||||
request_data = {"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "hi"}]}
|
||||
auth_obj = await _user_api_key_auth_builder(
|
||||
request=mock_request,
|
||||
api_key=f"Bearer {api_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data=request_data,
|
||||
)
|
||||
recovered = await _authorize_authenticated_request(
|
||||
user_api_key_auth_obj=auth_obj,
|
||||
request=mock_request,
|
||||
request_data=request_data,
|
||||
route="/v1/messages",
|
||||
api_key=f"Bearer {api_key}",
|
||||
)
|
||||
return recovered or auth_obj
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"team_member_spend, expect_blocked, expected_alerts",
|
||||
[
|
||||
(1.1, False, 0),
|
||||
(1.2, False, 1),
|
||||
(2.4, True, 1),
|
||||
],
|
||||
)
|
||||
async def test_cached_key_team_member_budget_emails_configured_thresholds(
|
||||
team_member_spend, expect_blocked, expected_alerts
|
||||
):
|
||||
"""The team's team_member_max_budget_alert_emails thresholds fire from the cached-key auth path,
|
||||
including on the request that trips the hard cap, and stay silent below the lowest threshold."""
|
||||
from litellm.proxy._types import LiteLLM_TeamMembership, LiteLLM_TeamTableCachedObj
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
team_membership_auth_cache_key,
|
||||
team_membership_reservation_cache_key,
|
||||
)
|
||||
from litellm.proxy.utils import hash_token
|
||||
|
||||
api_key = "sk-team-member-alert-thresholds"
|
||||
hashed_token = hash_token(api_key)
|
||||
team_id = "team-alert-thresholds"
|
||||
user_id = "user-alert-thresholds"
|
||||
alert_emails = {"50": [], "100": ["finance@example.com"]}
|
||||
|
||||
user_api_key_cache = DualCache()
|
||||
await _cache_key_object(
|
||||
hashed_token=hashed_token,
|
||||
user_api_key_obj=UserAPIKeyAuth(
|
||||
token=hashed_token,
|
||||
team_id=team_id,
|
||||
team_alias="platform",
|
||||
team_metadata={"team_member_max_budget_alert_emails": alert_emails},
|
||||
user_id=user_id,
|
||||
team_member_spend=team_member_spend,
|
||||
),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=f"team_id:{team_id}",
|
||||
value=LiteLLM_TeamTableCachedObj(
|
||||
team_id=team_id,
|
||||
team_alias="platform",
|
||||
metadata={"team_member_max_budget_alert_emails": alert_emails},
|
||||
),
|
||||
)
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=user_id,
|
||||
value=LiteLLM_UserTable(
|
||||
user_id=user_id, user_email="member@example.com", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
),
|
||||
)
|
||||
membership = LiteLLM_TeamMembership(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
spend=team_member_spend,
|
||||
budget_id="budget-alert-thresholds",
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=2.4),
|
||||
)
|
||||
# A live proxy holds the row under both keys, so any second team-member check in the
|
||||
# auth flow would find it too and send a duplicate alert.
|
||||
for membership_cache_key in (
|
||||
team_membership_reservation_cache_key(team_id=team_id, user_id=user_id),
|
||||
team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
|
||||
):
|
||||
await user_api_key_cache.async_set_cache(key=membership_cache_key, value=membership)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/messages"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {api_key}"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = SimpleNamespace()
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.budget_alerts = AsyncMock()
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock(return_value=None)
|
||||
|
||||
async def _auth():
|
||||
return await _authenticate_and_authorize(mock_request, api_key)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the builder reads proxy settings from module globals, no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {"disable_budget_reservation": True}
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-master"), # test-quality-ok: module-global proxy state
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: module-global proxy state
|
||||
patch( # test-quality-ok: seed the cached key, team and membership without a DB
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache
|
||||
),
|
||||
patch( # test-quality-ok: module-global proxy state
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj
|
||||
),
|
||||
patch( # test-quality-ok: the live counter needs Redis or a DB; pin the spend the check compares
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new=AsyncMock(return_value=team_member_spend),
|
||||
),
|
||||
):
|
||||
if expect_blocked:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await _auth()
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
else:
|
||||
await _auth()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert proxy_logging_obj.budget_alerts.await_count == expected_alerts
|
||||
if expected_alerts == 0:
|
||||
return
|
||||
call_info = proxy_logging_obj.budget_alerts.await_args.kwargs["user_info"]
|
||||
assert proxy_logging_obj.budget_alerts.await_args.kwargs["type"] == "max_budget_alert"
|
||||
assert call_info.event_group == Litellm_EntityType.TEAM_MEMBER
|
||||
assert (call_info.spend, call_info.max_budget) == (team_member_spend, 2.4)
|
||||
assert (call_info.user_id, call_info.user_email) == (user_id, "member@example.com")
|
||||
assert (call_info.team_id, call_info.team_alias) == (team_id, "platform")
|
||||
assert call_info.max_budget_alert_emails == alert_emails
|
||||
|
||||
|
||||
async def _proxy_exception_for_key(
|
||||
api_key: str,
|
||||
general_settings: dict[str, bool],
|
||||
|
|
|
|||
|
|
@ -1090,6 +1090,47 @@ async def test_multi_threshold_empty_emails_only_owner(
|
|||
assert to_emails == ["owner@co.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_threshold_team_member_alert_renders_member_template_per_team(
|
||||
base_email_logger, mock_send_email
|
||||
):
|
||||
"""A team member budget alert is keyed per member and team, names the member and team,
|
||||
and goes to the member plus the threshold's configured recipients"""
|
||||
user_info = CallInfo(
|
||||
user_id="member_1",
|
||||
user_email="member@co.com",
|
||||
team_id="team_a",
|
||||
team_alias="Platform",
|
||||
spend=0.10,
|
||||
max_budget=0.10,
|
||||
event_group=Litellm_EntityType.TEAM_MEMBER,
|
||||
max_budget_alert_emails={"50": [], "100": ["finance@co.com"]},
|
||||
)
|
||||
|
||||
mock_cache = mock.AsyncMock()
|
||||
mock_cache.async_increment_cache = mock.AsyncMock(return_value=1)
|
||||
base_email_logger.internal_usage_cache = mock_cache
|
||||
|
||||
with mock.patch.dict(os.environ, {"PROXY_BASE_URL": "http://test.com"}):
|
||||
await base_email_logger.budget_alerts(type="max_budget_alert", user_info=user_info)
|
||||
|
||||
cache_keys = sorted(c[1]["key"] for c in mock_cache.async_increment_cache.call_args_list)
|
||||
assert cache_keys == [
|
||||
"email_budget_alerts:max_budget_alert:100:team_member:member_1:team_a",
|
||||
"email_budget_alerts:max_budget_alert:50:team_member:member_1:team_a",
|
||||
]
|
||||
assert mock_send_email.call_count == 2
|
||||
hundred = next(
|
||||
c.kwargs for c in mock_send_email.call_args_list if "100%" in c.kwargs["subject"]
|
||||
)
|
||||
assert hundred["subject"] == "LiteLLM: Team Member Budget Alert - 100% of Team Member Budget Reached"
|
||||
assert sorted(hundred["to_email"]) == ["finance@co.com", "member@co.com"]
|
||||
assert "member@co.com" in hundred["html_body"] and "Platform" in hundred["html_body"]
|
||||
assert "team member budget" in hundred["html_body"] and "$0.1" in hundred["html_body"]
|
||||
fifty = next(c.kwargs for c in mock_send_email.call_args_list if "50%" in c.kwargs["subject"])
|
||||
assert fifty["to_email"] == ["member@co.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_map_preserves_old_single_threshold(
|
||||
base_email_logger, mock_send_email
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from litellm.integrations.SlackAlerting.budget_alert_types import SoftBudgetAlert
|
||||
from litellm.integrations.SlackAlerting.budget_alert_types import (
|
||||
SoftBudgetAlert,
|
||||
TokenBudgetAlert,
|
||||
)
|
||||
from litellm.proxy._types import CallInfo, Litellm_EntityType
|
||||
|
||||
|
||||
|
|
@ -64,3 +67,31 @@ class TestSoftBudgetAlert:
|
|||
|
||||
result = alert.get_id(user_info)
|
||||
assert result == "default_id"
|
||||
|
||||
|
||||
class TestTokenBudgetAlert:
|
||||
def test_get_id_dedupes_team_member_alerts_per_member_and_team(self):
|
||||
alert = TokenBudgetAlert()
|
||||
team_a = CallInfo(
|
||||
spend=8.0, max_budget=10.0, user_id="member_1", team_id="team_a", event_group=Litellm_EntityType.TEAM_MEMBER
|
||||
)
|
||||
team_b = CallInfo(
|
||||
spend=8.0, max_budget=10.0, user_id="member_1", team_id="team_b", event_group=Litellm_EntityType.TEAM_MEMBER
|
||||
)
|
||||
|
||||
assert alert.get_id(team_a) == "team_member:member_1:team_a"
|
||||
assert alert.get_id(team_b) == "team_member:member_1:team_b"
|
||||
|
||||
def test_get_id_uses_token_for_key_alerts(self):
|
||||
alert = TokenBudgetAlert()
|
||||
user_info = CallInfo(
|
||||
spend=8.0,
|
||||
max_budget=10.0,
|
||||
token="hashed_key",
|
||||
user_id="member_1",
|
||||
team_id="team_a",
|
||||
event_group=Litellm_EntityType.KEY,
|
||||
)
|
||||
|
||||
assert alert.get_id(user_info) == "hashed_key"
|
||||
assert alert.get_event_message() == "Key Budget: "
|
||||
|
|
|
|||
|
|
@ -393,6 +393,33 @@ def _slack_alerting_with_env_resolution() -> SlackAlerting:
|
|||
return slack_alerting
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"event_group, expected_prefix",
|
||||
[
|
||||
(Litellm_EntityType.TEAM_MEMBER, "Team Member Budget: Budget Crossed"),
|
||||
(Litellm_EntityType.KEY, "Key Budget: Budget Crossed"),
|
||||
],
|
||||
)
|
||||
async def test_max_budget_alert_labels_team_member_budget(event_group, expected_prefix):
|
||||
slack_alerting: Final = _slack_alerting_with_env_resolution()
|
||||
slack_alerting.send_alert = AsyncMock()
|
||||
|
||||
await slack_alerting.budget_alerts(
|
||||
type="max_budget_alert",
|
||||
user_info=CallInfo(
|
||||
spend=10.5,
|
||||
max_budget=10.0,
|
||||
token="hashed_key",
|
||||
user_id="member_1",
|
||||
team_id="team_a",
|
||||
event_group=event_group,
|
||||
),
|
||||
)
|
||||
|
||||
assert slack_alerting.send_alert.await_args.kwargs["message"].startswith(expected_prefix)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_alert_falls_back_to_alerting_webhook_url_env(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_WEBHOOK_URL", raising=False)
|
||||
|
|
|
|||
|
|
@ -2338,6 +2338,103 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
expect(wireBody(payload)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
const openEditorWithMemberBudgetAlerts = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
models: ["gpt-4"],
|
||||
team_member_budget_table: { max_budget: 42 },
|
||||
metadata: { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@test.com"] } },
|
||||
}),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText("Team Name");
|
||||
};
|
||||
|
||||
const memberBudgetAlertEmails = (payload: Record<string, unknown>) =>
|
||||
(wireBody(payload).metadata as Record<string, unknown>).team_member_max_budget_alert_emails;
|
||||
|
||||
it("resends the stored team member budget alert thresholds when the section stays closed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditorWithMemberBudgetAlerts(user);
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(memberBudgetAlertEmails(payload)).toStrictEqual({ "50": [], "100": ["finance@test.com"] });
|
||||
});
|
||||
|
||||
it("sends the edited team member budget alert thresholds as a percent to recipients map", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditorWithMemberBudgetAlerts(user);
|
||||
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await screen.findByLabelText("Default Budget (USD)");
|
||||
const thresholds = screen.getAllByPlaceholderText("% of budget");
|
||||
const recipients = screen.getAllByPlaceholderText(/Additional recipients/);
|
||||
expect(thresholds.map((input) => (input as HTMLInputElement).value)).toStrictEqual(["50", "100"]);
|
||||
expect(recipients.map((input) => (input as HTMLInputElement).value)).toStrictEqual(["", "finance@test.com"]);
|
||||
|
||||
fireEvent.change(thresholds[0], { target: { value: "75" } });
|
||||
fireEvent.change(recipients[0], { target: { value: " lead@test.com, finance@test.com " } });
|
||||
await user.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" }));
|
||||
fireEvent.change(screen.getAllByPlaceholderText("% of budget")[2], { target: { value: "90" } });
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(memberBudgetAlertEmails(payload)).toStrictEqual({
|
||||
"75": ["lead@test.com", "finance@test.com"],
|
||||
"100": ["finance@test.com"],
|
||||
"90": [],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the team member budget alert thresholds key once every row is removed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditorWithMemberBudgetAlerts(user);
|
||||
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await screen.findByLabelText("Default Budget (USD)");
|
||||
const removeButtons = screen.getAllByRole("button", { name: "Remove budget alert threshold" });
|
||||
await user.click(removeButtons[1]);
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(memberBudgetAlertEmails(payload)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks the save when a team member budget alert threshold is above 100", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditorWithMemberBudgetAlerts(user);
|
||||
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await screen.findByLabelText("Default Budget (USD)");
|
||||
const threshold = screen.getAllByPlaceholderText("% of budget")[0] as HTMLInputElement;
|
||||
fireEvent.change(threshold, { target: { value: "150" } });
|
||||
expect(threshold.validity.rangeOverflow).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(networking.teamUpdateCall).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("refuses to save a team member budget alert row with no threshold", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditorWithMemberBudgetAlerts(user);
|
||||
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await screen.findByLabelText("Default Budget (USD)");
|
||||
await user.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" }));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await screen.findByText("Enter a whole number from 1 to 100");
|
||||
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries every typed value to the update payload at the type and shape antd sends today", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await openEditor(user);
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ import {
|
|||
TEAM_INFO_TAB_LABELS,
|
||||
} from "./tabVisibilityUtils";
|
||||
import TeamMembersComponent from "./TeamMemberTab";
|
||||
import {
|
||||
isValidThreshold,
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY,
|
||||
teamMemberBudgetAlertEmailsFromRows,
|
||||
teamMemberBudgetAlertRowsFromMetadata,
|
||||
teamMemberBudgetAlertSummary,
|
||||
} from "./teamMemberBudgetAlertEmails";
|
||||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog";
|
||||
import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset";
|
||||
|
|
@ -128,6 +135,7 @@ const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
|
|||
"logging",
|
||||
"secret_manager_settings",
|
||||
"soft_budget_alerting_emails",
|
||||
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY,
|
||||
"model_tpm_limit",
|
||||
"model_rpm_limit",
|
||||
"default_estimated_output_tokens",
|
||||
|
|
@ -355,6 +363,18 @@ const teamUpdateFieldsSchema = z.object({
|
|||
team_member_key_duration: z.string().optional(),
|
||||
team_member_tpm_limit: numericInputSchema,
|
||||
team_member_rpm_limit: numericInputSchema,
|
||||
team_member_max_budget_alert_emails: z
|
||||
.array(z.object({ threshold: z.number().nullable(), emails: z.string() }))
|
||||
.superRefine((rows, ctx) => {
|
||||
rows.forEach((row, index) => {
|
||||
if (!isValidThreshold(row.threshold)) {
|
||||
ctx.addIssue({ code: "custom", message: "Enter a whole number from 1 to 100", path: [index, "threshold"] });
|
||||
} else if (rows.filter((other) => other.threshold === row.threshold).length > 1) {
|
||||
ctx.addIssue({ code: "custom", message: "Duplicate threshold", path: [index, "threshold"] });
|
||||
}
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
budget_duration: z.string().nullish(),
|
||||
tpm_limit: numericInputSchema,
|
||||
rpm_limit: numericInputSchema,
|
||||
|
|
@ -422,6 +442,7 @@ const TEAM_MEMBER_SETTINGS_FIELDS = [
|
|||
"team_member_key_duration",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
"team_member_max_budget_alert_emails",
|
||||
] as const;
|
||||
const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const;
|
||||
|
||||
|
|
@ -437,6 +458,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
|
|||
team_member_key_duration: undefined,
|
||||
team_member_tpm_limit: undefined,
|
||||
team_member_rpm_limit: undefined,
|
||||
team_member_max_budget_alert_emails: [],
|
||||
budget_duration: undefined,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
|
|
@ -487,6 +509,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
|
|||
team_member_key_duration: info.metadata?.team_member_key_duration,
|
||||
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
|
||||
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
|
||||
team_member_max_budget_alert_emails: [...teamMemberBudgetAlertRowsFromMetadata(info.metadata)],
|
||||
budget_duration: info.budget_duration,
|
||||
tpm_limit: info.tpm_limit,
|
||||
rpm_limit: info.rpm_limit,
|
||||
|
|
@ -572,6 +595,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
append: appendModelLimit,
|
||||
remove: removeModelLimit,
|
||||
} = useFieldArray({ control: form.control, name: "modelLimits" });
|
||||
const {
|
||||
fields: memberBudgetAlertRows,
|
||||
append: appendMemberBudgetAlertRow,
|
||||
remove: removeMemberBudgetAlertRow,
|
||||
} = useFieldArray({ control: form.control, name: "team_member_max_budget_alert_emails" });
|
||||
const [teamMemberSettingsOpen, setTeamMemberSettingsOpen] = useState(false);
|
||||
const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
|
||||
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||
|
|
@ -994,6 +1022,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
? { allowed_passthrough_routes: info.metadata.allowed_passthrough_routes }
|
||||
: {};
|
||||
|
||||
const memberBudgetAlertEmails =
|
||||
values.team_member_max_budget_alert_emails !== undefined
|
||||
? teamMemberBudgetAlertEmailsFromRows(values.team_member_max_budget_alert_emails)
|
||||
: info.metadata?.[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY];
|
||||
const memberBudgetAlertEmailsMetadata =
|
||||
memberBudgetAlertEmails !== undefined && Object.keys(memberBudgetAlertEmails).length > 0
|
||||
? { [TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]: memberBudgetAlertEmails }
|
||||
: {};
|
||||
|
||||
const updateData: any = {
|
||||
team_id: teamId,
|
||||
team_alias: values.team_alias,
|
||||
|
|
@ -1025,6 +1062,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
.filter((email: string) => email.length > 0)
|
||||
: values.soft_budget_alerting_emails || [],
|
||||
...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}),
|
||||
...memberBudgetAlertEmailsMetadata,
|
||||
},
|
||||
...(values.policies?.length > 0 ? { policies: values.policies } : {}),
|
||||
...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}),
|
||||
|
|
@ -1632,6 +1670,71 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<NumericalInput {...field} ref={ref} value={value ?? ""} step={1} placeholder="e.g., 100" />
|
||||
)}
|
||||
</FormField>
|
||||
<Field>
|
||||
<FieldLabel>
|
||||
{labelWithHint(
|
||||
"Budget Alert Thresholds",
|
||||
"Email each member when their spend reaches a percentage of their team member budget. The member is always notified; add comma-separated addresses to notify others as well. Requires email alerting to be configured on the proxy.",
|
||||
)}
|
||||
</FieldLabel>
|
||||
{memberBudgetAlertRows.map((row, index) => (
|
||||
<div key={row.id} className="mb-2 flex items-start gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`team_member_max_budget_alert_emails.${index}.threshold`}
|
||||
className="w-32"
|
||||
>
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<NumericalInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.target.value === "" ? null : Number(event.target.value))
|
||||
}
|
||||
placeholder="% of budget"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`team_member_max_budget_alert_emails.${index}.emails`}
|
||||
className="flex-1"
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<UIInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
placeholder="Additional recipients, e.g. finance@example.com"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove budget alert threshold"
|
||||
className="mt-1 text-destructive"
|
||||
onClick={() => removeMemberBudgetAlertRow(index)}
|
||||
>
|
||||
<CircleMinus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed"
|
||||
onClick={() => appendMemberBudgetAlertRow({ threshold: null, emails: "" })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Budget Alert Threshold
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
@ -2202,6 +2305,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<div>Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}</div>
|
||||
<div>TPM Limit: {info.team_member_budget_table?.tpm_limit ?? "No Limit"}</div>
|
||||
<div>RPM Limit: {info.team_member_budget_table?.rpm_limit ?? "No Limit"}</div>
|
||||
<div>Budget Alert Thresholds: {teamMemberBudgetAlertSummary(info.metadata).join("; ") || "None"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Router Settings</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidThreshold,
|
||||
teamMemberBudgetAlertEmailsFromRows,
|
||||
teamMemberBudgetAlertRowsFromMetadata,
|
||||
teamMemberBudgetAlertSummary,
|
||||
} from "./teamMemberBudgetAlertEmails";
|
||||
|
||||
describe("teamMemberBudgetAlertRowsFromMetadata", () => {
|
||||
it("turns the stored threshold map into rows sorted by threshold", () => {
|
||||
const metadata = {
|
||||
team_member_max_budget_alert_emails: { "100": ["finance@example.com", "cto@example.com"], "50": [] },
|
||||
};
|
||||
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([
|
||||
{ threshold: 50, emails: "" },
|
||||
{ threshold: 100, emails: "finance@example.com, cto@example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops non-numeric thresholds and non-list recipients instead of crashing", () => {
|
||||
const metadata = {
|
||||
team_member_max_budget_alert_emails: { fifty: [], "75": "finance@example.com", "90": [1], "100": ["a@b.c"] },
|
||||
};
|
||||
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) => {
|
||||
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("teamMemberBudgetAlertEmailsFromRows", () => {
|
||||
it("builds the threshold map, splitting, trimming and deduplicating recipients", () => {
|
||||
expect(
|
||||
teamMemberBudgetAlertEmailsFromRows([
|
||||
{ threshold: 50, emails: "" },
|
||||
{ threshold: 100, emails: " finance@example.com,cto@example.com , finance@example.com, " },
|
||||
]),
|
||||
).toEqual({ "50": [], "100": ["finance@example.com", "cto@example.com"] });
|
||||
});
|
||||
|
||||
it("skips rows without a valid threshold", () => {
|
||||
expect(
|
||||
teamMemberBudgetAlertEmailsFromRows([
|
||||
{ threshold: null, emails: "finance@example.com" },
|
||||
{ threshold: 0, emails: "" },
|
||||
{ threshold: 101, emails: "" },
|
||||
{ threshold: 12.5, emails: "" },
|
||||
{ threshold: 80, emails: "" },
|
||||
]),
|
||||
).toEqual({ "80": [] });
|
||||
});
|
||||
|
||||
it("round-trips the stored config", () => {
|
||||
const stored = { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@example.com"] } };
|
||||
expect(teamMemberBudgetAlertEmailsFromRows(teamMemberBudgetAlertRowsFromMetadata(stored))).toEqual(
|
||||
stored.team_member_max_budget_alert_emails,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidThreshold", () => {
|
||||
it.each([
|
||||
[1, true],
|
||||
[50, true],
|
||||
[100, true],
|
||||
[0, false],
|
||||
[101, false],
|
||||
[33.3, false],
|
||||
[null, false],
|
||||
])("treats %s as valid=%s", (threshold, valid) => {
|
||||
expect(isValidThreshold(threshold)).toBe(valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamMemberBudgetAlertSummary", () => {
|
||||
it("states that the member is always notified and lists extra recipients", () => {
|
||||
expect(
|
||||
teamMemberBudgetAlertSummary({
|
||||
team_member_max_budget_alert_emails: { "100": ["finance@example.com"], "50": [] },
|
||||
}),
|
||||
).toEqual(["50%: member", "100%: member, finance@example.com"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
export const TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY = "team_member_max_budget_alert_emails" as const;
|
||||
|
||||
export interface TeamMemberBudgetAlertRow {
|
||||
readonly threshold: number | null;
|
||||
readonly emails: string;
|
||||
}
|
||||
|
||||
export type TeamMemberBudgetAlertEmails = Readonly<Record<string, readonly string[]>>;
|
||||
|
||||
const isEmailList = (value: unknown): value is readonly string[] =>
|
||||
Array.isArray(value) && value.every((email) => typeof email === "string");
|
||||
|
||||
const splitEmails = (emails: string): readonly string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
emails
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
const THRESHOLD_MIN = 1;
|
||||
const THRESHOLD_MAX = 100;
|
||||
|
||||
export const isValidThreshold = (threshold: number | null): threshold is number => {
|
||||
const isWholeNumber = threshold !== null && Number.isInteger(threshold);
|
||||
return isWholeNumber && threshold >= THRESHOLD_MIN && threshold <= THRESHOLD_MAX;
|
||||
};
|
||||
|
||||
export const teamMemberBudgetAlertRowsFromMetadata = (metadata: unknown): readonly TeamMemberBudgetAlertRow[] => {
|
||||
if (typeof metadata !== "object" || metadata === null) return [];
|
||||
const config: unknown = (metadata as Record<string, unknown>)[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY];
|
||||
if (typeof config !== "object" || config === null || Array.isArray(config)) return [];
|
||||
return Object.entries(config as Record<string, unknown>)
|
||||
.flatMap(([key, emails]) => {
|
||||
const threshold = Number(key);
|
||||
return /^\d+$/.test(key) && isValidThreshold(threshold) && isEmailList(emails)
|
||||
? [{ threshold, emails: emails.join(", ") }]
|
||||
: [];
|
||||
})
|
||||
.sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0));
|
||||
};
|
||||
|
||||
export const teamMemberBudgetAlertEmailsFromRows = (
|
||||
rows: readonly TeamMemberBudgetAlertRow[],
|
||||
): TeamMemberBudgetAlertEmails =>
|
||||
Object.fromEntries(
|
||||
rows
|
||||
.filter((row) => isValidThreshold(row.threshold))
|
||||
.map((row) => [String(row.threshold), splitEmails(row.emails)]),
|
||||
);
|
||||
|
||||
export const teamMemberBudgetAlertSummary = (metadata: unknown): readonly string[] =>
|
||||
teamMemberBudgetAlertRowsFromMetadata(metadata).map((row) =>
|
||||
row.emails.length > 0 ? `${row.threshold}%: member, ${row.emails}` : `${row.threshold}%: member`,
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue