From da2ecbc5bb1b84991e77e26dce126c48f3034756 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Thu, 5 Nov 2026 16:14:12 +0800 Subject: [PATCH] feat: email affected owners before model deprecations (#50172) * feat: opt-in model deprecation emails for affected owners * fix: annotate deprecation sender initialization * fix: clarify deprecation emails and use consistent settings toggles * fix: exclude former team admins from deprecation emails * feat: clarify provider deprecations and model migration steps --- .../types/enterprise_callbacks/send_emails.py | 28 +- .../model_deprecation_email.py | 181 +++++++++++ .../integrations/model_deprecation_email.py | 267 ++++++++++++++++ litellm/proxy/config_resolvers/alerting.py | 1 + litellm/proxy/utils.py | 30 +- litellm/repositories/config_repository.py | 9 + litellm/repositories/table_repositories.py | 4 + .../integrations/model_deprecation_email.py | 35 ++ .../integrations/email_alerting/__init__.py | 0 .../test_model_deprecation_email.py | 298 ++++++++++++++++++ .../utils/prisma_and_spend/test_send_email.py | 25 +- .../public/assets/logos/anthropic.png | Bin 0 -> 3402 bytes .../public/assets/logos/aws.png | Bin 0 -> 2317 bytes .../public/assets/logos/microsoft_azure.png | Bin 0 -> 4236 bytes .../public/assets/logos/openai.png | Bin 0 -> 5646 bytes .../DeprecationEmailSetup.test.tsx | 26 ++ .../DeprecationEmailSetup.tsx | 76 +++++ .../ModelDeprecationsPanel.tsx | 2 + .../email_event_settings.test.tsx | 32 +- .../email_events/email_event_settings.tsx | 12 +- .../src/components/email_settings.tsx | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 22 files changed, 1006 insertions(+), 26 deletions(-) create mode 100644 litellm/integrations/email_templates/model_deprecation_email.py create mode 100644 litellm/integrations/model_deprecation_email.py create mode 100644 litellm/types/integrations/model_deprecation_email.py create mode 100644 tests/unit/integrations/email_alerting/__init__.py create mode 100644 tests/unit/integrations/email_alerting/test_model_deprecation_email.py create mode 100644 ui/litellm-dashboard/public/assets/logos/anthropic.png create mode 100644 ui/litellm-dashboard/public/assets/logos/aws.png create mode 100644 ui/litellm-dashboard/public/assets/logos/microsoft_azure.png create mode 100644 ui/litellm-dashboard/public/assets/logos/openai.png create mode 100644 ui/litellm-dashboard/src/components/ModelDeprecationAlerts/DeprecationEmailSetup.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ModelDeprecationAlerts/DeprecationEmailSetup.tsx diff --git a/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py index a2322d7f650..bb3b712d50b 100644 --- a/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py +++ b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py @@ -1,9 +1,9 @@ import enum -from typing import Dict, List, Optional from pydantic import Field from litellm.proxy._types import WebhookEvent +from litellm.types.integrations.model_deprecation_email import MODEL_DEPRECATION_EMAIL_EVENT from litellm.types.llms.base import LiteLLMBaseModel @@ -26,7 +26,7 @@ class SendKeyCreatedEmailEvent(WebhookEvent): class SendKeyRotatedEmailEvent(WebhookEvent): virtual_key: str - key_alias: Optional[str] = None + key_alias: str | None = None """ The virtual key that was rotated this will be sk-123xxx, since we will be emailing this to the user to start using the new key @@ -34,23 +34,33 @@ class SendKeyRotatedEmailEvent(WebhookEvent): class EmailEvent(str, enum.Enum): + model_deprecations = MODEL_DEPRECATION_EMAIL_EVENT virtual_key_created = "Virtual Key Created" new_user_invitation = "New User Invitation" virtual_key_rotated = "Virtual Key Rotated" soft_budget_crossed = "Soft Budget Crossed" max_budget_alert = "Max Budget Alert" + class EmailEventSettings(LiteLLMBaseModel): event: EmailEvent enabled: bool + + class EmailEventSettingsUpdateRequest(LiteLLMBaseModel): - settings: List[EmailEventSettings] + settings: list[EmailEventSettings] + + class EmailEventSettingsResponse(LiteLLMBaseModel): - settings: List[EmailEventSettings] + settings: list[EmailEventSettings] + + class DefaultEmailSettings(LiteLLMBaseModel): """Default settings for email events""" - settings: Dict[EmailEvent, bool] = Field( + + settings: dict[EmailEvent, bool] = Field( default_factory=lambda: { + EmailEvent.model_deprecations: False, # Explicit opt-in for owner retirement reminders. EmailEvent.virtual_key_created: True, # On by default EmailEvent.new_user_invitation: True, # On by default EmailEvent.virtual_key_rotated: True, # On by default @@ -58,10 +68,12 @@ class DefaultEmailSettings(LiteLLMBaseModel): EmailEvent.max_budget_alert: True, # On by default } ) - def to_dict(self) -> Dict[str, bool]: + + def to_dict(self) -> dict[str, bool]: """Convert to dictionary with string keys for storage""" return {event.value: enabled for event, enabled in self.settings.items()} + @classmethod - def get_defaults(cls) -> Dict[str, bool]: + def get_defaults(cls) -> dict[str, bool]: """Get the default settings as a dictionary with string keys""" - return cls().to_dict() \ No newline at end of file + return cls().to_dict() diff --git a/litellm/integrations/email_templates/model_deprecation_email.py b/litellm/integrations/email_templates/model_deprecation_email.py new file mode 100644 index 00000000000..5d325b2442a --- /dev/null +++ b/litellm/integrations/email_templates/model_deprecation_email.py @@ -0,0 +1,181 @@ +"""Model deprecation emails with provider identity, a migration comparison and next steps.""" + +from collections.abc import Mapping +from html import escape +from types import MappingProxyType +from typing import Final +from urllib.parse import urlencode, urlsplit + +from litellm.types.integrations.model_deprecation_email import ( + DeprecationEmailMilestone, + ModelDeprecationEmail, + RenderedDeprecationEmail, +) +from litellm.types.proxy.model_deprecation import SuccessorModelSource + +_LOGO_BASE_URL: Final = ( + "https://raw.githubusercontent.com/BerriAI/litellm/main/ui/litellm-dashboard/public/assets/logos" +) +_EMAIL_LOGO_URL: Final = f"{_LOGO_BASE_URL}/litellm_logo.png" +# Reuse dashboard brand assets; PNGs also render in email clients that block SVG. +_PROVIDER_BRANDS: Final[Mapping[str, tuple[str, str]]] = MappingProxyType( + { + "gemini": ("Google", "google_pse.png"), + "vertex_ai": ("Google", "google_pse.png"), + "openai": ("OpenAI", "openai.png"), + "anthropic": ("Anthropic", "anthropic.png"), + "azure": ("Microsoft Azure", "microsoft_azure.png"), + "bedrock": ("AWS Bedrock", "aws.png"), + "bedrock_converse": ("AWS Bedrock", "aws.png"), + } +) + + +def _recommendation_source(source: SuccessorModelSource | None) -> str: + if source is SuccessorModelSource.DEPLOYMENT_CONFIG: + return "Gateway configuration" + if source is SuccessorModelSource.MODEL_COST_MAP: + return "LiteLLM model cost map" + return "Not provided" + + +def _http_url(value: str | None) -> str | None: + if value is None: + return None + try: + parsed: Final = urlsplit(value) + except ValueError: + return None + return value if parsed.scheme in ("http", "https") and parsed.netloc and not parsed.username else None + + +def _provider(model: str, fallback: str | None = None) -> str: + prefix, separator, _ = model.partition("/") + return prefix if separator else fallback or "" + + +def _provider_name(provider: str) -> str: + brand: Final = _PROVIDER_BRANDS.get(provider) + if brand: + return brand[0] + return provider.replace("_", " ") or "The provider" + + +def _model_html(model: str, provider: str) -> str: + brand: Final = _PROVIDER_BRANDS.get(provider) + logo: Final = ( + f'{escape(brand[0], quote=True)}' + if brand + else "" + ) + return ( + f'{logo}' + f'
{escape(_provider_name(provider))}
' + f"
{escape(model)}
" + ) + + +def render_model_deprecation_email(email: ModelDeprecationEmail, *, base_url: str) -> RenderedDeprecationEmail: + gateway: Final = _http_url(base_url) + if gateway is None: + raise ValueError("Model deprecation emails require a public HTTP(S) PROXY_BASE_URL") + urgent: Final = email.milestone is DeprecationEmailMilestone.ONE_DAY + days: Final = int(email.milestone) + timing: Final = "tomorrow" if urgent else "in 30 days" + provider: Final = _provider(email.usage_model, email.model.litellm_provider) + provider_name: Final = _provider_name(provider) + subject: Final = f"[LiteLLM] Model being deprecated by {provider_name} {timing}: {email.usage_model}" + deadline: Final = email.model.deprecation_date.strftime("%d %b %Y") + announcement: Final = f"{provider_name} is deprecating this model on {deadline} (UTC)." + preheader: Final = f"{announcement} Review the model change and migrate your affected keys." + query: Final = urlencode( + { + "tab": "models", + "from": email.start_date.isoformat(), + "to": email.end_date.isoformat(), + } + ) + review_url: Final = f"{gateway.rstrip('/')}/ui/usage?{query}" + replacement: Final = email.model.successor_model + after: Final = ( + _model_html(replacement, _provider(replacement, provider)) + if replacement + else '

No recommended model listed. Choose a supported replacement.

' + ) + source: Final = _recommendation_source(email.model.successor_model_source) + notice: Final = _http_url(email.model.deprecation_source) + source_link: Final = ( + f'Read the provider notice ↗' + if notice + else "" + ) + team_names: Final = ", ".join(email.team_names[:3]) or "Personal keys" + more_teams: Final = f" + {len(email.team_names) - 3} more" if len(email.team_names) > 3 else "" + day_label: Final = "DAY" if urgent else "DAYS" + label: Final = "FINAL REMINDER" if urgent else "DEPRECATION NOTICE" + test_step: Final = ( + "Test the recommended model with your workload." + if replacement + else "Choose a supported replacement and test it with your workload." + ) + switch_step: Final = ( + f"Switch your app or gateway routing before {deadline}. Then check that requests to the old model reach zero." + ) + html: Final = f""" + +{escape(subject)} + + +
{escape(preheader)}
+
+ + + + + +
+ +
LiteLLM/ MODEL DEPRECATIONS
+ +
{label}{days:02d} {day_label} UNTIL DEPRECATION
+

Model being deprecated

+

{escape(announcement)}

+
+ + +
+
BEFORE · MODEL TO REPLACE
+{_model_html(email.usage_model, provider)} +
+
AFTER · RECOMMENDED MODEL
+{after} +
+

Recommendation source: {escape(source)}
{source_link}

+

Your affected usage

+ + +
{email.key_count:,}
Affected keys
{email.requests:,}
Requests · last 30 days
+

{escape(team_names + more_teams)}
Your keys and teams you administer.

+

What to do

+
    +
  1. Review the affected keys in your usage dashboard.
  2. +
  3. {escape(test_step)}
  4. +
  5. {escape(switch_step)}
+
+Review affected usage ↗
+
+Your gateway admin enabled these emails. Reminder schedule: 30 days and 1 day before deprecation.

LiteLLM · AI Gateway
""" + text: Final = ( + f"{subject}\n\n{announcement}\n\n" + f"BEFORE - MODEL TO REPLACE\n{email.usage_model}\n\n" + f"AFTER - RECOMMENDED MODEL\n{replacement or 'No recommended model listed. Choose a supported replacement.'}\n" + f"Recommendation source: {source}\nProvider notice: {notice or 'Not provided'}\n\n" + f"YOUR AFFECTED USAGE\n{email.key_count} keys | {email.requests} requests in the last 30 days\n" + f"{team_names}{more_teams}\nYour keys and teams you administer.\n\n" + f"WHAT TO DO\n1. Review the affected keys in your usage dashboard.\n2. {test_step}\n3. {switch_step}\n\n" + f"Review affected usage: {review_url}\n\n" + "Your gateway admin enabled these emails. Reminder schedule: 30 days and 1 day before deprecation." + ) + return RenderedDeprecationEmail(subject=subject, html=html, text=text) diff --git a/litellm/integrations/model_deprecation_email.py b/litellm/integrations/model_deprecation_email.py new file mode 100644 index 00000000000..4093a108dcc --- /dev/null +++ b/litellm/integrations/model_deprecation_email.py @@ -0,0 +1,267 @@ +"""Two owner-scoped retirement reminders, with durable per-recipient delivery receipts.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from enum import Enum +from itertools import chain +from types import MappingProxyType +from typing import TYPE_CHECKING, Final + +from pydantic import AliasPath, BaseModel, ConfigDict, Field, TypeAdapter + +from litellm.integrations.email_templates.model_deprecation_email import ( + render_model_deprecation_email, +) +from litellm.models.team import LiteLLM_TeamTable +from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations +from litellm.repositories.config_repository import ConfigRepository +from litellm.repositories.table_repositories import DailyUserSpendRepository +from litellm.repositories.team_repository import TeamRepository +from litellm.repositories.user_repository import UserRepository +from litellm.repositories.verification_token_repository import ( + VerificationTokenRepository, +) +from litellm.types.integrations.model_deprecation_email import ( + MODEL_DEPRECATION_EMAIL_EVENT, + DeprecationEmailMilestone, + ModelDeprecationEmail, +) +from litellm.types.proxy.model_deprecation import ( + DEPRECATION_IDLE_POLL_SECONDS, + ModelDeprecationInfo, +) + +if TYPE_CHECKING: + from litellm import Router + from litellm.proxy.utils import PrismaClient + +_RECEIPT_PREFIX: Final = "model_deprecation_email:" +_PAGE_SIZE: Final = 100 + + +class _OptIn(BaseModel): + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + enabled: bool = Field( + False, + strict=True, + validation_alias=AliasPath("email_settings", MODEL_DEPRECATION_EMAIL_EVENT), + ) + + +class _RequestCount(BaseModel): + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + api_requests: int + + +class _UsedKey(BaseModel): + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + api_key: str + metrics: _RequestCount = Field(alias="_sum") + + +_USED_KEYS: Final = TypeAdapter(tuple[_UsedKey, ...]) + + +class _DeliveryStatus(str, Enum): + SENDING = "sending" + SENT = "sent" + + +class _Receipt(BaseModel): + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + revision: int = 0 + status: _DeliveryStatus = _DeliveryStatus.SENDING + lease_until: datetime + + +@dataclass(frozen=True, slots=True) +class _Impact: + key_count: int = 0 + requests: int = 0 + teams: frozenset[str] = frozenset() + + +def _admin_ids(team: LiteLLM_TeamTable | None) -> frozenset[str]: + if team is None: + return frozenset() + return frozenset( + member.user_id for member in (team.members_with_roles or ()) if member.role == "admin" and member.user_id + ) + + +def _recipient_emails( + user_id: str | None, team: LiteLLM_TeamTable | None, addresses: Mapping[str, str] +) -> frozenset[str]: + owner_ids: Final[frozenset[str]] = frozenset((user_id,)) if user_id else frozenset() + resolved: Final = frozenset(addresses[owner] for owner in owner_ids | _admin_ids(team) if owner in addresses) + if team is None: + return resolved + return resolved | frozenset( + member.user_email.strip() + for member in (team.members_with_roles or ()) + if member.role == "admin" + and member.user_email + and member.user_email.strip() + and member.user_id not in addresses + ) + + +def _usage_model(model: ModelDeprecationInfo) -> str: + name: Final = model.litellm_model or model.model_name + provider: Final = model.litellm_provider + if provider and not name.startswith(f"{provider}/"): + return f"{provider}/{name}" + return name + + +def _receipt_key(email: ModelDeprecationEmail) -> str: + identity: Final = json.dumps( + [ + email.usage_model, + email.model.deprecation_date.isoformat(), + int(email.milestone), + email.recipient_email.casefold(), + ] + ) + return _RECEIPT_PREFIX + hashlib.sha256(identity.encode()).hexdigest() + + +class ModelDeprecationEmailSender: + def __init__(self, prisma_client: PrismaClient) -> None: + self.prisma: Final = prisma_client + self.config: Final = ConfigRepository(prisma_client, use_writer=True) + + async def _impacts(self, model: str, start: date, end: date) -> Mapping[str, _Impact]: + impacts: Final[dict[str, _Impact]] = {} # mutable-ok: combines per-recipient counts across bounded usage pages + offset: int = 0 # rebind-ok: each page advances the stable key grouping cursor + while True: + raw = await DailyUserSpendRepository(self.prisma).table.group_by( + by=["api_key"], + where={ + "model": model, + "date": {"gte": start.isoformat(), "lte": end.isoformat()}, + "api_requests": {"gt": 0}, + }, + sum={"api_requests": True}, + order={"api_key": "asc"}, + skip=offset, + take=_PAGE_SIZE, + ) + usage = _USED_KEYS.validate_python(raw) + if not usage: + return MappingProxyType(impacts) + keys = await VerificationTokenRepository(self.prisma).find_many_in("token", (row.api_key for row in usage)) + teams = await TeamRepository(self.prisma).find_many_in( + "team_id", frozenset(key.team_id for key in keys if key.team_id) + ) + team_by_id = {team.team_id: team for team in teams} + ids = frozenset(key.user_id for key in keys if key.user_id) | frozenset( + chain.from_iterable(_admin_ids(team) for team in teams) + ) + users = await UserRepository(self.prisma).find_many_in("user_id", ids) + addresses = { + user.user_id: user.user_email.strip() for user in users if user.user_email and user.user_email.strip() + } + counts = {row.api_key: row.metrics.api_requests for row in usage} + for key in keys: + team = team_by_id.get(key.team_id) if key.team_id else None + if key.token is None: + continue + recipients = _recipient_emails(key.user_id, team, addresses) + for address in recipients: + previous = impacts.get(address, _Impact()) + names: frozenset[str] = frozenset((team.team_alias or team.team_id,)) if team else frozenset() + impacts[address] = _Impact( + key_count=previous.key_count + 1, + requests=previous.requests + counts[key.token], + teams=previous.teams | names, + ) + if len(usage) < _PAGE_SIZE: + return MappingProxyType(impacts) + offset += _PAGE_SIZE + + async def _deliver(self, email: ModelDeprecationEmail, now: datetime, base_url: str) -> bool: + from litellm.proxy.utils import send_email + + key: Final = _receipt_key(email) + record: Final = await self.config.get_param(key) + previous: Final = _Receipt.model_validate(record.param_value) if record else None + if previous and (previous.status is _DeliveryStatus.SENT or previous.lease_until > now): + return False + revision: Final = previous.revision if previous else 0 + claim: Final = _Receipt(revision=revision + 1, lease_until=now + timedelta(minutes=5)) + if not await self.config.set_param_if_revision(key, claim.model_dump(mode="json"), revision): + return False + content: Final = render_model_deprecation_email(email, base_url=base_url) + if not await send_email( + receiver_email=email.recipient_email, + subject=content.subject, + html=content.html, + text=content.text, + message_id=f"", + ): + return False + sent: Final = claim.model_copy(update={"revision": claim.revision + 1, "status": _DeliveryStatus.SENT}) + if not await self.config.set_param_if_revision(key, sent.model_dump(mode="json"), claim.revision): + raise RuntimeError("Could not record the delivered model deprecation reminder") + return True + + async def run(self, llm_router: Router, *, now: datetime | None = None) -> int: + current: Final = now or datetime.now(timezone.utc) + settings: Final = await self.config.get_param("general_settings") + if settings is None or not _OptIn.model_validate(settings.param_value).enabled: + return 0 + base_url: Final = os.getenv("PROXY_BASE_URL", "") + if not base_url: + return 0 + await self.config.delete_params_older_than(_RECEIPT_PREFIX, current - timedelta(days=60)) + snapshot: Final = collect_model_deprecations(llm_router=llm_router, today=current.date()) + start: Final = current.date() - timedelta(days=29) + seen: Final[set[tuple[str, date]]] = set() # mutable-ok: deduplicates aliases in this pass + sent: int = 0 # rebind-ok: counts successfully delivered reminders in this pass + for model in snapshot.imminent: + if model.days_until_deprecation not in tuple(DeprecationEmailMilestone): + continue + name = _usage_model(model) + identity = (name, model.deprecation_date) + if identity in seen: + continue + seen.add(identity) + impacts = await self._impacts(name, start, current.date()) + for address, impact in sorted(impacts.items()): + email = ModelDeprecationEmail( + model=model, + usage_model=name, + recipient_email=address, + milestone=DeprecationEmailMilestone(model.days_until_deprecation), + key_count=impact.key_count, + requests=impact.requests, + team_names=tuple(sorted(impact.teams)), + start_date=start, + end_date=current.date(), + ) + if await self._deliver(email, now or datetime.now(timezone.utc), base_url): + sent += 1 + return sent + + +async def run_scheduled_model_deprecation_emails() -> None: + """Poll the email opt-in independently; a large owner mailing never delays Slack checks.""" + from litellm._logging import verbose_proxy_logger + + while True: + try: + from litellm.proxy.proxy_server import llm_router, prisma_client + + if llm_router is not None and prisma_client is not None: + await ModelDeprecationEmailSender(prisma_client).run(llm_router) + except Exception: # noqa: BLE001 # transient SMTP/DB failures must not terminate the reminder loop + verbose_proxy_logger.exception("Error sending model deprecation email reminders") + await asyncio.sleep(DEPRECATION_IDLE_POLL_SECONDS) diff --git a/litellm/proxy/config_resolvers/alerting.py b/litellm/proxy/config_resolvers/alerting.py index 4de7197f88b..1d7fbe0fffa 100644 --- a/litellm/proxy/config_resolvers/alerting.py +++ b/litellm/proxy/config_resolvers/alerting.py @@ -11,6 +11,7 @@ from typing import Final from litellm.proxy.config_resolvers._descriptors import FieldDescriptor EMAIL_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = ( + FieldDescriptor("PROXY_BASE_URL", "PROXY_BASE_URL", "PROXY_BASE_URL"), FieldDescriptor("SMTP_HOST", "SMTP_HOST", "SMTP_HOST"), FieldDescriptor("SMTP_PORT", "SMTP_PORT", "SMTP_PORT", default="587"), FieldDescriptor("SMTP_TLS", "SMTP_TLS", "SMTP_TLS", default="True"), diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 71ecae74058..a2928bba287 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -28,6 +28,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formatdate from functools import partial, reduce from itertools import takewhile from operator import attrgetter @@ -1952,6 +1953,7 @@ class ProxyLogging: self.daily_report_started: bool = False self.hanging_requests_check_started: bool = False self.deprecation_check_started: bool = False + self.deprecation_email_check_started: bool = False def startup_event( self, @@ -1995,6 +1997,20 @@ class ProxyLogging: self.hanging_requests_check_started = True self._ensure_deprecation_check_scheduled() + self._ensure_deprecation_email_check_scheduled() + + def _ensure_deprecation_email_check_scheduled(self) -> None: + """The email event opt-in controls reminders independently of Slack integration settings.""" + if self.deprecation_email_check_started: + return + try: + asyncio.get_running_loop() + except RuntimeError: + return + from litellm.integrations.model_deprecation_email import run_scheduled_model_deprecation_emails + + asyncio.create_task(run_scheduled_model_deprecation_emails()) + self.deprecation_email_check_started = True def _ensure_deprecation_check_scheduled(self) -> None: """Alerting can be configured at startup or by a later config reload, so schedule from either path""" @@ -2041,6 +2057,7 @@ class ProxyLogging: if updated_slack_alerting is True: self._ensure_deprecation_check_scheduled() + self._ensure_deprecation_email_check_scheduled() self.slack_alerting_instance.update_values( alerting=self.alerting, alerting_threshold=self.alerting_threshold, @@ -8263,7 +8280,9 @@ async def send_email( receiver_email: str | None = None, subject: str | None = None, html: str | None = None, -): + text: str | None = None, + message_id: str | None = None, +) -> bool: """ smtp_host, smtp_port, @@ -8289,16 +8308,21 @@ async def send_email( raise ValueError(f"No HTML body provided for SMTP email. {html}") ## EMAIL SETUP ## - email_message: Final = MIMEMultipart() + email_message: Final = MIMEMultipart("alternative" if text is not None else "mixed") email_message["From"] = sender_email email_message["To"] = receiver_email email_message["Subject"] = subject + if message_id is not None: + email_message["Message-ID"] = message_id + email_message["Date"] = formatdate(usegmt=True) verbose_proxy_logger.debug("sending email from %s to %s", sender_email, receiver_email) if smtp_host is None: raise ValueError("Trying to use SMTP, but SMTP_HOST is not set") # Attach the body to the email + if text is not None: + email_message.attach(MIMEText(text, "plain")) email_message.attach(MIMEText(html, "html")) try: @@ -8317,6 +8341,8 @@ async def send_email( except Exception as e: verbose_proxy_logger.exception("An error occurred while sending the email:" + str(e)) + return False + return True def hash_token(token: str): diff --git a/litellm/repositories/config_repository.py b/litellm/repositories/config_repository.py index 8768d79b0dd..36c6ab7f711 100644 --- a/litellm/repositories/config_repository.py +++ b/litellm/repositories/config_repository.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from collections.abc import Mapping, Sequence +from datetime import datetime from typing import TYPE_CHECKING, Final, Protocol, cast if TYPE_CHECKING: @@ -35,6 +36,8 @@ class _ConfigTable(Protocol): async def delete(self, *, where: Mapping[str, str]) -> _ConfigRow | None: ... + async def delete_many(self, *, where: Mapping[str, object]) -> int: ... + class _ConfigDatabase(Protocol): async def query_raw(self, query: str, *args: object) -> object: ... @@ -130,3 +133,9 @@ class ConfigRepository: param_value = _decoded_json(param_value) result[record.param_name] = param_value return result + + async def delete_params_older_than(self, prefix: str, before: datetime) -> int: + """Expire operational config records using the timestamp written by revision claims.""" + return await self._config_table.delete_many( + where={"param_name": {"startswith": prefix}, "last_run_at": {"lt": before}} + ) diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 10db81eca4b..d010455faa3 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -387,3 +387,7 @@ class BackgroundInteractionSettlementRepository( class RetiredAgentRepository(PrismaTableRepository["prisma_models.LiteLLM_RetiredAgent"]): table_name = "litellm_retiredagent" + + +class DailyUserSpendRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyUserSpend"]): + table_name = "litellm_dailyuserspend" diff --git a/litellm/types/integrations/model_deprecation_email.py b/litellm/types/integrations/model_deprecation_email.py new file mode 100644 index 00000000000..f196bb47544 --- /dev/null +++ b/litellm/types/integrations/model_deprecation_email.py @@ -0,0 +1,35 @@ +"""Model retirement email milestones and recipient-scoped content.""" + +from dataclasses import dataclass +from datetime import date +from enum import IntEnum +from typing import Final + +from litellm.types.proxy.model_deprecation import ModelDeprecationInfo + +MODEL_DEPRECATION_EMAIL_EVENT: Final = "Model Deprecations" + + +class DeprecationEmailMilestone(IntEnum): + THIRTY_DAYS = 30 + ONE_DAY = 1 + + +@dataclass(frozen=True, slots=True) +class ModelDeprecationEmail: + model: ModelDeprecationInfo + usage_model: str + recipient_email: str + milestone: DeprecationEmailMilestone + key_count: int + requests: int + team_names: tuple[str, ...] + start_date: date + end_date: date + + +@dataclass(frozen=True, slots=True) +class RenderedDeprecationEmail: + subject: str + html: str + text: str diff --git a/tests/unit/integrations/email_alerting/__init__.py b/tests/unit/integrations/email_alerting/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/integrations/email_alerting/test_model_deprecation_email.py b/tests/unit/integrations/email_alerting/test_model_deprecation_email.py new file mode 100644 index 00000000000..54dbe342148 --- /dev/null +++ b/tests/unit/integrations/email_alerting/test_model_deprecation_email.py @@ -0,0 +1,298 @@ +"""Retirement reminder schedule, durable dedupe, ownership scope and safe rendering.""" + +import asyncio +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone +from types import MappingProxyType +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.integrations import model_deprecation_email as module +from litellm.integrations.email_templates.model_deprecation_email import ( + render_model_deprecation_email, +) +from litellm.models.team import LiteLLM_TeamTable +from litellm.models.user import LiteLLM_UserTable +from litellm.proxy._types import LiteLLM_VerificationToken +from litellm.repositories.config_repository import ConfigParam +from litellm.types.integrations.model_deprecation_email import ( + DeprecationEmailMilestone, + ModelDeprecationEmail, +) +from litellm.types.proxy.model_deprecation import ( + ModelDeprecationInfo, + ModelDeprecationResponse, + SuccessorModelSource, +) + +NOW = datetime(2026, 11, 1, tzinfo=timezone.utc) + + +def info(days=30, model="gemini/gemini-2.5-pro"): + return ModelDeprecationInfo( + model_name="research", + litellm_model=model, + deprecation_date=NOW.date() + timedelta(days=days), + days_until_deprecation=days, + status="imminent", + successor_model="gemini/gemini-3.8-flash", + successor_model_source=SuccessorModelSource.MODEL_COST_MAP, + deprecation_source="https://ai.google.dev/gemini-api/docs/deprecations", + ) + + +class MemoryConfig: + def __init__(self): + self.rows = {"general_settings": {"email_settings": {"Model Deprecations": True}}} + + async def get_param(self, key): + return ConfigParam(key, self.rows[key]) if key in self.rows else None + + async def set_param_if_revision(self, key, value, revision): + current = self.rows.get(key, {}).get("revision", 0) + if current != revision: + return False + self.rows[key] = value + return True + + async def delete_params_older_than(self, prefix, before): + return 0 + + +@pytest.fixture() +def sender(monkeypatch): + monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example") + instance = module.ModelDeprecationEmailSender(MagicMock()) + instance.config = MemoryConfig() + instance._impacts = AsyncMock( + return_value=MappingProxyType( + {"owner@example.com": module._Impact(key_count=3, requests=12, teams=frozenset({"Research"}))} + ) + ) + send = AsyncMock(return_value=True) + monkeypatch.setattr("litellm.proxy.utils.send_email", send) + return instance, send + + +def schedule(monkeypatch, models): + snapshot = ModelDeprecationResponse( + deprecated=[], imminent=models, upcoming=[], warn_within_days=30, checked_at=NOW + ) + monkeypatch.setattr(module, "collect_model_deprecations", lambda **kwargs: snapshot) + + +@pytest.mark.asyncio +async def test_should_send_only_the_two_milestones_and_keep_receipts_across_restart(sender, monkeypatch): + instance, send = sender + schedule(monkeypatch, [info()]) + assert await instance.run(MagicMock(), now=NOW) == 1 + assert await instance.run(MagicMock(), now=NOW) == 0 + restarted = module.ModelDeprecationEmailSender(MagicMock()) + restarted.config = instance.config + restarted._impacts = instance._impacts + assert await restarted.run(MagicMock(), now=NOW) == 0 + final_day = NOW + timedelta(days=29) + schedule(monkeypatch, [info().model_copy(update={"days_until_deprecation": 1})]) + assert await restarted.run(MagicMock(), now=final_day) == 1 + assert await restarted.run(MagicMock(), now=final_day) == 0 + assert send.await_count == 2 + assert "in 30 days" in send.await_args_list[0].kwargs["subject"] + assert "tomorrow" in send.await_args_list[1].kwargs["subject"] + assert send.await_args_list[0].kwargs["message_id"] != send.await_args_list[1].kwargs["message_id"] + assert "alert("x")', + "deprecation_source": "javascript:alert(1)", + } + ) + email = ModelDeprecationEmail( + model=model, + usage_model=model.litellm_model, + recipient_email="owner@example.com", + milestone=DeprecationEmailMilestone.THIRTY_DAYS, + key_count=3, + requests=12, + team_names=("Research",), + start_date=date(2026, 10, 3), + end_date=NOW.date(), + ) + rendered = render_model_deprecation_email(email, base_url="https://gateway.example") + assert "/public/assets/logos/litellm_logo.png" in rendered.html + assert "litellm-listing.s3.amazonaws.com" not in rendered.html + assert "