bump: version 0.1.25 → 0.1.26

This commit is contained in:
yuneng-jiang 2025-12-15 16:00:37 -08:00
parent d8663efeab
commit 7a18c81cf4
10 changed files with 242 additions and 41 deletions

View file

@ -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"<b>Maximum Budget:</b> ${event.max_budget} <br />"
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,

View file

@ -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==",

View file

@ -60,3 +60,27 @@ USER_INVITED_EMAIL_TEMPLATE = """
Best, <br />
The LiteLLM team <br />
"""
SOFT_BUDGET_ALERT_EMAIL_TEMPLATE = """
<img src="{email_logo_url}" alt="LiteLLM Logo" width="150" height="50" />
<p> Hi {recipient_email}, <br/>
Your LiteLLM API key has crossed its <b>soft budget limit of {soft_budget}</b>. <br /> <br />
<b>Current Spend:</b> {spend} <br />
<b>Soft Budget:</b> {soft_budget} <br />
{max_budget_info}
<p style="color: #dc2626; font-weight: 500;">
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.
</p>
You can view your usage and manage your budget in the <a href="{base_url}">LiteLLM Dashboard</a>. <br /> <br />
If you have any questions, please send an email to {email_support_contact} <br /> <br />
Best, <br />
The LiteLLM team <br />
"""

View file

@ -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",

View file

@ -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

View file

@ -15,6 +15,5 @@ litellm_settings:
"prod": 0.9 # 90% reserved for production
"dev": 0.1 # 10% reserved for development
general_settings:
alerting: ["email"]

View file

@ -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"]

View file

@ -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,

View file

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

View file

@ -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