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)}]({_LOGO_BASE_URL}/{brand[1]}) | '
+ 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)}
+
+
+
+ |
+/ 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
+
+- Review the affected keys in your usage dashboard.
+- {escape(test_step)}
+- {escape(switch_step)}
+
+ |
+
+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 "