litellm/tests/integration/_support/mail.py
devin-ai-integration[bot] d08746feb1
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>
2026-09-26 02:42:45 +00:00

127 lines
4.4 KiB
Python

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}"