Added LiteLLM to the stack

This commit is contained in:
2025-08-18 09:40:50 +00:00
parent 0648c1968c
commit d220b04e32
2682 changed files with 533609 additions and 1 deletions

View File

@@ -0,0 +1,325 @@
"""
Base class for sending emails to user after creating keys or invite links
"""
import json
import os
from typing import List, Optional
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
EmailEvent,
EmailParams,
SendKeyCreatedEmailEvent,
)
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER
from litellm.integrations.email_templates.key_created_email import (
KEY_CREATED_EMAIL_TEMPLATE,
)
from litellm.integrations.email_templates.user_invitation_email import (
USER_INVITATION_EMAIL_TEMPLATE,
)
from litellm.proxy._types import InvitationNew, UserAPIKeyAuth, WebhookEvent
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
class BaseEmailLogger(CustomLogger):
DEFAULT_LITELLM_EMAIL = "notifications@alerts.litellm.ai"
DEFAULT_SUPPORT_EMAIL = "support@berri.ai"
DEFAULT_SUBJECT_TEMPLATES = {
EmailEvent.new_user_invitation: "LiteLLM: {event_message}",
EmailEvent.virtual_key_created: "LiteLLM: {event_message}",
}
async def send_user_invitation_email(self, event: WebhookEvent):
"""
Send email to user after inviting them to the team
"""
email_params = await self._get_email_params(
email_event=EmailEvent.new_user_invitation,
user_id=event.user_id,
user_email=getattr(event, "user_email", None),
event_message=event.event_message,
)
verbose_proxy_logger.debug(
f"send_user_invitation_email_event: {json.dumps(event, indent=4, default=str)}"
)
email_html_content = USER_INVITATION_EMAIL_TEMPLATE.format(
email_logo_url=email_params.logo_url,
recipient_email=email_params.recipient_email,
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=[email_params.recipient_email],
subject=email_params.subject,
html_body=email_html_content,
)
pass
async def send_key_created_email(
self, send_key_created_email_event: SendKeyCreatedEmailEvent
):
"""
Send email to user after creating key for the user
"""
email_params = await self._get_email_params(
user_id=send_key_created_email_event.user_id,
user_email=send_key_created_email_event.user_email,
email_event=EmailEvent.virtual_key_created,
event_message=send_key_created_email_event.event_message,
)
verbose_proxy_logger.debug(
f"send_key_created_email_event: {json.dumps(send_key_created_email_event, indent=4, default=str)}"
)
email_html_content = KEY_CREATED_EMAIL_TEMPLATE.format(
email_logo_url=email_params.logo_url,
recipient_email=email_params.recipient_email,
key_budget=self._format_key_budget(send_key_created_email_event.max_budget),
key_token=send_key_created_email_event.virtual_key,
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=[email_params.recipient_email],
subject=email_params.subject,
html_body=email_html_content,
)
pass
async def _get_email_params(
self,
email_event: EmailEvent,
user_id: Optional[str] = None,
user_email: Optional[str] = None,
event_message: Optional[str] = None,
) -> EmailParams:
"""
Get common email parameters used across different email sending methods
Args:
email_event: Type of email event
user_id: Optional user ID to look up email
user_email: Optional direct email address
event_message: Optional message to include in email subject
Returns:
EmailParams object containing logo_url, support_contact, base_url, recipient_email, subject, and signature
"""
# Get email parameters with premium check for custom values
custom_logo = os.getenv("EMAIL_LOGO_URL", None)
custom_support = os.getenv("EMAIL_SUPPORT_CONTACT", None)
custom_signature = os.getenv("EMAIL_SIGNATURE", None)
custom_subject_invitation = os.getenv("EMAIL_SUBJECT_INVITATION", None)
custom_subject_key_created = os.getenv("EMAIL_SUBJECT_KEY_CREATED", None)
# Track which custom values were not applied
unused_custom_fields = []
# Function to safely get custom value or default
def get_custom_or_default(custom_value: Optional[str], default_value: str, field_name: str) -> str:
if custom_value is not None: # Only check premium if trying to use custom value
from litellm.proxy.proxy_server import premium_user
if premium_user is not True:
unused_custom_fields.append(field_name)
return default_value
return custom_value
return default_value
# Get parameters, falling back to defaults if custom values aren't allowed
logo_url = get_custom_or_default(custom_logo, LITELLM_LOGO_URL, "logo URL")
support_contact = get_custom_or_default(custom_support, self.DEFAULT_SUPPORT_EMAIL, "support contact")
base_url = os.getenv("PROXY_BASE_URL", "http://0.0.0.0:4000") # Not a premium feature
signature = get_custom_or_default(custom_signature, EMAIL_FOOTER, "email signature")
# Get custom subject template based on email event type
if email_event == EmailEvent.new_user_invitation:
subject_template = get_custom_or_default(
custom_subject_invitation,
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.new_user_invitation],
"invitation subject template"
)
elif email_event == EmailEvent.virtual_key_created:
subject_template = get_custom_or_default(
custom_subject_key_created,
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_created],
"key created subject template"
)
else:
subject_template = "LiteLLM: {event_message}"
subject = subject_template.format(event_message=event_message) if event_message else "LiteLLM Notification"
recipient_email: Optional[
str
] = user_email or await self._lookup_user_email_from_db(user_id=user_id)
if recipient_email is None:
raise ValueError(
f"User email not found for user_id: {user_id}. User email is required to send email."
)
# if user invited event then send invitation link
if email_event == EmailEvent.new_user_invitation:
base_url = await self._get_invitation_link(
user_id=user_id, base_url=base_url
)
# If any custom fields were not applied, log a warning
if unused_custom_fields:
fields_str = ", ".join(unused_custom_fields)
warning_msg = (
f"Email sent with default values instead of custom values for: {fields_str}. "
"This is an Enterprise feature. To use custom email fields, please upgrade to LiteLLM Enterprise. "
"Schedule a meeting here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat"
)
verbose_proxy_logger.warning(
f"{warning_msg}"
)
return EmailParams(
logo_url=logo_url,
support_contact=support_contact,
base_url=base_url,
recipient_email=recipient_email,
subject=subject,
signature=signature,
)
def _format_key_budget(self, max_budget: Optional[float]) -> str:
"""
Format the key budget to be displayed in the email
"""
if max_budget is None:
return "No budget"
return f"${max_budget}"
async def _lookup_user_email_from_db(self, user_id: Optional[str]) -> Optional[str]:
"""
Lookup user email from user_id
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
verbose_proxy_logger.debug(
f"Prisma client not found. Unable to lookup user email for user_id: {user_id}"
)
return None
user_row = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
)
if user_row is not None:
return user_row.user_email
return None
async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> str:
"""
Get invitation link for the user
"""
# Early validation
if not user_id:
verbose_proxy_logger.debug("No user_id provided for invitation link")
return base_url
if not await self._is_prisma_client_available():
return base_url
# Wait for any concurrent invitation creation to complete
await self._wait_for_invitation_creation()
# Get or create invitation
invitation = await self._get_or_create_invitation(user_id)
if not invitation:
verbose_proxy_logger.warning(f"Failed to get/create invitation for user_id: {user_id}")
return base_url
return self._construct_invitation_link(invitation.id, base_url)
async def _is_prisma_client_available(self) -> bool:
"""Check if Prisma client is available"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
verbose_proxy_logger.debug("Prisma client not found. Unable to lookup invitation")
return False
return True
async def _wait_for_invitation_creation(self) -> None:
"""
Wait for any concurrent invitation creation to complete.
The UI calls /invitation/new to generate the invitation link.
We wait to ensure any pending invitation creation is completed.
"""
import asyncio
await asyncio.sleep(10)
async def _get_or_create_invitation(self, user_id: str):
"""
Get existing invitation or create a new one for the user
Returns:
Invitation object with id attribute, or None if failed
"""
from litellm.proxy.management_helpers.user_invitation import (
create_invitation_for_user,
)
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
verbose_proxy_logger.error("Prisma client is None in _get_or_create_invitation")
return None
try:
# Try to get existing invitation
existing_invitations = await prisma_client.db.litellm_invitationlink.find_many(
where={"user_id": user_id},
order={"created_at": "desc"},
)
if existing_invitations and len(existing_invitations) > 0:
verbose_proxy_logger.debug(f"Found existing invitation for user_id: {user_id}")
return existing_invitations[0]
# Create new invitation if none exists
verbose_proxy_logger.debug(f"Creating new invitation for user_id: {user_id}")
return await create_invitation_for_user(
data=InvitationNew(user_id=user_id),
user_api_key_dict=UserAPIKeyAuth(user_id=user_id),
)
except Exception as e:
verbose_proxy_logger.error(f"Error getting/creating invitation for user_id {user_id}: {e}")
return None
def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str:
"""
Construct invitation link for the user
# http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
"""
return f"{base_url}/ui?invitation_id={invitation_id}"
async def send_email(
self,
from_email: str,
to_email: List[str],
subject: str,
html_body: str,
):
pass

View File

@@ -0,0 +1,202 @@
"""
Endpoints for managing email alerts on litellm
"""
import json
from typing import Dict
from fastapi import APIRouter, Depends, HTTPException
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
DefaultEmailSettings,
EmailEvent,
EmailEventSettings,
EmailEventSettingsResponse,
EmailEventSettingsUpdateRequest,
)
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
router = APIRouter()
async def _get_email_settings(prisma_client) -> Dict[str, bool]:
"""Helper function to get email settings from general_settings in db"""
try:
# Get general settings from db
general_settings_entry = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "general_settings"}
)
# Initialize with default email settings
settings_dict = DefaultEmailSettings.get_defaults()
if (
general_settings_entry is not None
and general_settings_entry.param_value is not None
):
# Get general settings value
if isinstance(general_settings_entry.param_value, str):
general_settings = json.loads(general_settings_entry.param_value)
else:
general_settings = general_settings_entry.param_value
# Extract email_settings from general settings if it exists
if general_settings and "email_settings" in general_settings:
email_settings = general_settings["email_settings"]
# Update settings_dict with values from general_settings
for event_name, enabled in email_settings.items():
settings_dict[event_name] = enabled
return settings_dict
except Exception as e:
verbose_proxy_logger.error(
f"Error getting email settings from general_settings: {str(e)}"
)
# Return default settings in case of error
return DefaultEmailSettings.get_defaults()
async def _save_email_settings(prisma_client, settings: Dict[str, bool]):
"""Helper function to save email settings to general_settings in db"""
try:
verbose_proxy_logger.debug(
f"Saving email settings to general_settings: {settings}"
)
# Get current general settings
general_settings_entry = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "general_settings"}
)
# Initialize general settings dict
if (
general_settings_entry is not None
and general_settings_entry.param_value is not None
):
if isinstance(general_settings_entry.param_value, str):
general_settings = json.loads(general_settings_entry.param_value)
else:
general_settings = dict(general_settings_entry.param_value)
else:
general_settings = {}
# Update email_settings in general_settings
general_settings["email_settings"] = settings
# Convert to JSON for storage
json_settings = json.dumps(general_settings, default=str)
# Save updated general settings
await prisma_client.db.litellm_config.upsert(
where={"param_name": "general_settings"},
data={
"create": {
"param_name": "general_settings",
"param_value": json_settings,
},
"update": {"param_value": json_settings},
},
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error saving email settings to general_settings: {str(e)}",
)
@router.get(
"/email/event_settings",
response_model=EmailEventSettingsResponse,
tags=["email management"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_email_event_settings(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get all email event settings
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
# Get existing settings
settings_dict = await _get_email_settings(prisma_client)
# Create a response with all events (enabled or disabled)
response_settings = []
for event in EmailEvent:
enabled = settings_dict.get(event.value, False)
response_settings.append(EmailEventSettings(event=event, enabled=enabled))
return EmailEventSettingsResponse(settings=response_settings)
except Exception as e:
verbose_proxy_logger.exception(f"Error getting email settings: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch(
"/email/event_settings",
tags=["email management"],
dependencies=[Depends(user_api_key_auth)],
)
async def update_event_settings(
request: EmailEventSettingsUpdateRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Update the settings for email events
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
# Get existing settings
settings_dict = await _get_email_settings(prisma_client)
# Update with new settings
for setting in request.settings:
settings_dict[setting.event.value] = setting.enabled
# Save updated settings
await _save_email_settings(prisma_client, settings_dict)
return {"message": "Email event settings updated successfully"}
except Exception as e:
verbose_proxy_logger.exception(f"Error updating email settings: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/email/event_settings/reset",
tags=["email management"],
dependencies=[Depends(user_api_key_auth)],
)
async def reset_event_settings(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Reset all email event settings to default (new user invitations on, virtual key creation off)
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
# Reset to default settings using the Pydantic model
default_settings = DefaultEmailSettings.get_defaults()
# Save default settings
await _save_email_settings(prisma_client, default_settings)
return {"message": "Email event settings reset to defaults"}
except Exception as e:
verbose_proxy_logger.exception(f"Error resetting email settings: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,51 @@
"""
This is the litellm x resend email integration
https://resend.com/docs/api-reference/emails/send-email
"""
import os
from typing import List
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from .base_email import BaseEmailLogger
RESEND_API_ENDPOINT = "https://api.resend.com/emails"
class ResendEmailLogger(BaseEmailLogger):
def __init__(self):
self.async_httpx_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback
)
self.resend_api_key = os.getenv("RESEND_API_KEY")
async def send_email(
self,
from_email: str,
to_email: List[str],
subject: str,
html_body: str,
):
verbose_logger.debug(
f"Sending email from {from_email} to {to_email} with subject {subject}"
)
response = await self.async_httpx_client.post(
url=RESEND_API_ENDPOINT,
json={
"from": from_email,
"to": to_email,
"subject": subject,
"html": html_body,
},
headers={"Authorization": f"Bearer {self.resend_api_key}"},
)
verbose_logger.debug(
f"Email sent with status code {response.status_code}. Got response: {response.json()}"
)
return

View File

@@ -0,0 +1,47 @@
"""
This is the litellm SMTP email integration
"""
import asyncio
from typing import List
from litellm._logging import verbose_logger
from .base_email import BaseEmailLogger
class SMTPEmailLogger(BaseEmailLogger):
"""
This is the litellm SMTP email integration
Required SMTP environment variables:
- SMTP_HOST
- SMTP_PORT
- SMTP_USERNAME
- SMTP_PASSWORD
- SMTP_SENDER_EMAIL
"""
def __init__(self):
verbose_logger.debug("SMTP Email Logger initialized....")
async def send_email(
self,
from_email: str,
to_email: List[str],
subject: str,
html_body: str,
):
from litellm.proxy.utils import send_email as send_smtp_email
verbose_logger.debug(
f"Sending email from {from_email} to {to_email} with subject {subject}"
)
for receiver_email in to_email:
asyncio.create_task(
send_smtp_email(
receiver_email=receiver_email,
subject=subject,
html=html_body,
)
)
return