diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 1fe82c2c188..e02a14613ed 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -5,7 +5,7 @@ Base class for sending emails to user after creating keys or invite links import json import os -from typing import List, Optional +from typing import List, Literal, Optional from litellm_enterprise.types.enterprise_callbacks.send_emails import ( EmailEvent, @@ -15,6 +15,7 @@ from litellm_enterprise.types.enterprise_callbacks.send_emails import ( ) from litellm._logging import verbose_proxy_logger +from litellm.caching.caching import DualCache 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 ( @@ -26,7 +27,10 @@ from litellm.integrations.email_templates.key_rotated_email import ( from litellm.integrations.email_templates.user_invitation_email import ( USER_INVITATION_EMAIL_TEMPLATE, ) -from litellm.proxy._types import InvitationNew, UserAPIKeyAuth, WebhookEvent +from litellm.integrations.email_templates.templates import ( + SOFT_BUDGET_ALERT_EMAIL_TEMPLATE, +) +from litellm.proxy._types import CallInfo, InvitationNew, UserAPIKeyAuth, WebhookEvent from litellm.secret_managers.main import get_secret_bool from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL @@ -39,6 +43,22 @@ class BaseEmailLogger(CustomLogger): EmailEvent.virtual_key_created: "LiteLLM: {event_message}", EmailEvent.virtual_key_rotated: "LiteLLM: {event_message}", } + DEFAULT_BUDGET_ALERT_TTL = 24 * 60 * 60 # 24 hours in seconds + + def __init__( + self, + internal_usage_cache: Optional[DualCache] = None, + **kwargs, + ): + """ + Initialize BaseEmailLogger + + Args: + internal_usage_cache: DualCache instance for preventing duplicate alerts + **kwargs: Additional arguments passed to CustomLogger + """ + super().__init__(**kwargs) + self.internal_usage_cache = internal_usage_cache or DualCache() async def send_user_invitation_email(self, event: WebhookEvent): """ @@ -154,6 +174,124 @@ class BaseEmailLogger(CustomLogger): ) pass + async def send_soft_budget_alert_email(self, event: WebhookEvent): + """ + Send email to user when soft budget is crossed + """ + print("SENDING SOFT BUDGET ALERT EMAIL", event.json()) + email_params = await self._get_email_params( + email_event=EmailEvent.virtual_key_created, # Reuse existing event type for subject template + user_id=event.user_id, + user_email=event.user_email, + event_message=event.event_message or "Soft Budget Crossed", + ) + + verbose_proxy_logger.debug( + f"send_soft_budget_alert_email_event: {json.dumps(event.model_dump(exclude_none=True), indent=4, default=str)}" + ) + + # Format budget values + soft_budget_str = f"${event.soft_budget}" if event.soft_budget is not None else "N/A" + spend_str = f"${event.spend}" if event.spend is not None else "$0.00" + max_budget_info = "" + if event.max_budget is not None: + max_budget_info = f"Maximum Budget: ${event.max_budget}
" + + email_html_content = SOFT_BUDGET_ALERT_EMAIL_TEMPLATE.format( + email_logo_url=email_params.logo_url, + recipient_email=email_params.recipient_email, + soft_budget=soft_budget_str, + spend=spend_str, + max_budget_info=max_budget_info, + base_url=email_params.base_url, + email_support_contact=email_params.support_contact, + ) + + 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 budget_alerts( + self, + type: Literal[ + "token_budget", + "soft_budget", + "user_budget", + "team_budget", + "organization_budget", + "proxy_budget", + "projected_limit_exceeded", + ], + user_info: CallInfo, + ): + """ + Send a budget alert via email + + Args: + type: The type of budget alert to send + user_info: The user info to send the alert for + """ + ## PREVENTITIVE ALERTING ## + # - Alert once within 24hr period + # - Cache this information + # - Don't re-alert, if alert already sent + _cache: DualCache = self.internal_usage_cache + + # percent of max_budget left to spend + if user_info.max_budget is None and user_info.soft_budget is None: + return + + # For soft_budget alerts, check if we've already sent an alert + if type == "soft_budget": + if user_info.soft_budget is not None and user_info.spend >= user_info.soft_budget: + # Generate cache key based on event type and identifier + _id = user_info.token or user_info.user_id or "default_id" + _cache_key = f"email_budget_alerts:soft_budget_crossed:{_id}" + + # Check if we've already sent this alert + result = await _cache.async_get_cache(key=_cache_key) + if result is None: + # Create WebhookEvent for soft budget alert + event_message = f"Soft Budget Crossed - Total Soft Budget: ${user_info.soft_budget}" + webhook_event = WebhookEvent( + event="soft_budget_crossed", + event_message=event_message, + spend=user_info.spend, + max_budget=user_info.max_budget, + soft_budget=user_info.soft_budget, + token=user_info.token, + customer_id=user_info.customer_id, + user_id=user_info.user_id, + team_id=user_info.team_id, + team_alias=user_info.team_alias, + organization_id=user_info.organization_id, + user_email=user_info.user_email, + key_alias=user_info.key_alias, + projected_exceeded_date=user_info.projected_exceeded_date, + projected_spend=user_info.projected_spend, + event_group=user_info.event_group, + ) + + try: + await self.send_soft_budget_alert_email(webhook_event) + + # Cache the alert to prevent duplicate sends + await _cache.async_set_cache( + key=_cache_key, + value="SENT", + ttl=self.DEFAULT_BUDGET_ALERT_TTL, + ) + except Exception as e: + verbose_proxy_logger.error( + f"Error sending soft budget alert email: {e}", + exc_info=True, + ) + return + async def _get_email_params( self, email_event: EmailEvent, diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index 2bcd8d33adc..ecd06fb08f3 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-enterprise" -version = "0.1.25" +version = "0.1.26" description = "Package for LiteLLM Enterprise features" authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.1.25" +version = "0.1.26" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-enterprise==", diff --git a/litellm/integrations/email_templates/templates.py b/litellm/integrations/email_templates/templates.py index 7029e8ce12a..2ec6372caa4 100644 --- a/litellm/integrations/email_templates/templates.py +++ b/litellm/integrations/email_templates/templates.py @@ -60,3 +60,27 @@ USER_INVITED_EMAIL_TEMPLATE = """ Best,
The LiteLLM team
""" + +SOFT_BUDGET_ALERT_EMAIL_TEMPLATE = """ + LiteLLM Logo + +

Hi {recipient_email},
+ + Your LiteLLM API key has crossed its soft budget limit of {soft_budget}.

+ + Current Spend: {spend}
+ Soft Budget: {soft_budget}
+ {max_budget_info} + +

+ ⚠️ Note: Your API requests will continue to work, but you should monitor your usage closely. + If you reach your maximum budget, requests will be rejected. +

+ + You can view your usage and manage your budget in the LiteLLM Dashboard.

+ + If you have any questions, please send an email to {email_support_contact}

+ + Best,
+ The LiteLLM team
+""" \ No newline at end of file diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 309bd577606..936ab05f16d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -1965,6 +1965,8 @@ async def _virtual_key_soft_budget_check( key_alias=valid_token.key_alias, event_group=Litellm_EntityType.KEY, ) + + print("VIRTUAL KEY SOFT BUDGET CHECK", call_info.json()) asyncio.create_task( proxy_logging_obj.budget_alerts( type="soft_budget", diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index d0c284e921c..584b06ba792 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1066,6 +1066,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 await _virtual_key_soft_budget_check( valid_token=valid_token, proxy_logging_obj=proxy_logging_obj, + user_obj=user_obj, ) # Check 5. Token Model Spend is under Model budget diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 47b8f2e9457..91bbed21291 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -15,6 +15,5 @@ litellm_settings: "prod": 0.9 # 90% reserved for production "dev": 0.1 # 10% reserved for development - - - +general_settings: + alerting: ["email"] diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index da09346503d..76b69b284ab 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2748,19 +2748,26 @@ class ProxyConfig: verbose_proxy_logger.debug(f"_alerting_callbacks: {general_settings}") if _alerting_callbacks is None: return + + # Ensure proxy_logging_obj.alerting is set for all alerting types + _alerting_value = general_settings.get("alerting", None) + print("ALERTING VALUES", _alerting_value) + verbose_proxy_logger.debug(f"_load_alerting_settings: Calling update_values with alerting={_alerting_value}") + proxy_logging_obj.update_values( + alerting=_alerting_value, + alerting_threshold=general_settings.get("alerting_threshold", 600), + alert_types=general_settings.get("alert_types", None), + alert_to_webhook_url=general_settings.get( + "alert_to_webhook_url", None + ), + alerting_args=general_settings.get("alerting_args", None), + redis_cache=redis_usage_cache, + ) + for _alert in _alerting_callbacks: if _alert == "slack": - # [OLD] v0 implementation - proxy_logging_obj.update_values( - alerting=general_settings.get("alerting", None), - alerting_threshold=general_settings.get("alerting_threshold", 600), - alert_types=general_settings.get("alert_types", None), - alert_to_webhook_url=general_settings.get( - "alert_to_webhook_url", None - ), - alerting_args=general_settings.get("alerting_args", None), - redis_cache=redis_usage_cache, - ) + # [OLD] v0 implementation - already handled by update_values above + pass else: # [NEW] v1 implementation - init as a custom logger if _alert in litellm._known_custom_logger_compatible_callbacks: @@ -3227,6 +3234,7 @@ class ProxyConfig: proxy_logging_obj: ProxyLogging """ _general_settings = config_data.get("general_settings", {}) + if _general_settings is not None and "alerting" in _general_settings: if ( general_settings is not None @@ -3235,29 +3243,37 @@ class ProxyConfig: and _general_settings.get("alerting", None) is not None and isinstance(_general_settings["alerting"], list) ): - verbose_proxy_logger.debug( - "Overriding Default 'alerting' values with db 'alerting' values." - ) - general_settings["alerting"] = _general_settings[ - "alerting" - ] # override yaml values with db - proxy_logging_obj.alerting = general_settings["alerting"] - proxy_logging_obj.slack_alerting_instance.alerting = general_settings[ - "alerting" + # Merge DB and YAML/config alerting values instead of overriding + _yaml_alerting = set(general_settings["alerting"]) + _db_alerting = set(_general_settings["alerting"]) + _merged_alerting = list(_yaml_alerting.union(_db_alerting)) + # Preserve order: YAML values first, then DB values + _merged_alerting = list(general_settings["alerting"]) + [ + item for item in _general_settings["alerting"] + if item not in general_settings["alerting"] ] + print("_add_general_settings_from_db_config: MERGING alerting from", general_settings.get("alerting"), "and", _general_settings["alerting"], "to", _merged_alerting) + verbose_proxy_logger.debug( + f"Merging alerting values: YAML={general_settings['alerting']}, DB={_general_settings['alerting']}, Merged={_merged_alerting}" + ) + general_settings["alerting"] = _merged_alerting + # Use update_values to properly set alerting for both slack and email + proxy_logging_obj.update_values( + alerting=general_settings["alerting"], + ) elif general_settings is None: general_settings = {} general_settings["alerting"] = _general_settings["alerting"] - proxy_logging_obj.alerting = general_settings["alerting"] - proxy_logging_obj.slack_alerting_instance.alerting = general_settings[ - "alerting" - ] + # Use update_values to properly set alerting for both slack and email + proxy_logging_obj.update_values( + alerting=general_settings["alerting"], + ) elif isinstance(general_settings, dict): general_settings["alerting"] = _general_settings["alerting"] - proxy_logging_obj.alerting = general_settings["alerting"] - proxy_logging_obj.slack_alerting_instance.alerting = general_settings[ - "alerting" - ] + # Use update_values to properly set alerting for both slack and email + proxy_logging_obj.update_values( + alerting=general_settings["alerting"], + ) if _general_settings is not None and "alert_types" in _general_settings: general_settings["alert_types"] = _general_settings["alert_types"] diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 275baa88da8..e1a33885b45 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -35,6 +35,11 @@ from litellm.proxy._types import ( from litellm.types.guardrails import GuardrailEventHooks from litellm.types.utils import CallTypes, CallTypesLiteral +try: + from litellm_enterprise.enterprise_callbacks.send_emails.base_email import BaseEmailLogger +except ImportError: + BaseEmailLogger = None # type: ignore + try: import backoff except ImportError: @@ -266,6 +271,11 @@ class ProxyLogging: alerting=self.alerting, internal_usage_cache=self.internal_usage_cache.dual_cache, ) + self.email_logging_instance: Optional[Any] = None + if BaseEmailLogger is not None: + self.email_logging_instance = BaseEmailLogger( + internal_usage_cache=self.internal_usage_cache.dual_cache, + ) self.premium_user = premium_user self.service_logging_obj = ServiceLogging() self.db_spend_update_writer = DBSpendUpdateWriter() @@ -1156,13 +1166,24 @@ class ProxyLogging: ], user_info: CallInfo, ): + print("BUDGET ALERTS", type, user_info) + print("ALERTING", self.alerting) if self.alerting is None: # do nothing if alerting is not switched on return - await self.slack_alerting_instance.budget_alerts( - type=type, - user_info=user_info, - ) + + if "slack" in self.alerting: + await self.slack_alerting_instance.budget_alerts( + type=type, + user_info=user_info, + ) + + if "email" in self.alerting and self.email_logging_instance is not None: + print("BUDGET ALERTS EMAIL", type, user_info) + await self.email_logging_instance.budget_alerts( + type=type, + user_info=user_info, + ) async def alerting_handler( self, diff --git a/pyproject.toml b/pyproject.toml index 3ac63bc214a..ab0b0479b9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3. mcp = {version = "^1.21.2", optional = true, python = ">=3.10"} litellm-proxy-extras = {version = "0.4.14", optional = true} rich = {version = "13.7.1", optional = true} -litellm-enterprise = {version = "0.1.25", optional = true} +litellm-enterprise = {version = "0.1.26", optional = true} diskcache = {version = "^5.6.1", optional = true} polars = {version = "^1.31.0", optional = true, python = ">=3.10"} semantic-router = {version = ">=0.1.12", optional = true, python = ">=3.9,<3.14"} diff --git a/requirements.txt b/requirements.txt index 3e64600fc64..54a965b6fbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -66,4 +66,4 @@ soundfile==0.12.1 # for audio file processing ######################## # LITELLM ENTERPRISE DEPENDENCIES ######################## -litellm-enterprise==0.1.25 +litellm-enterprise==0.1.26