diff --git a/enterprise/dist/litellm_enterprise-0.1.30-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.30-py3-none-any.whl
new file mode 100644
index 0000000000..0165bb096c
Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.30-py3-none-any.whl differ
diff --git a/enterprise/dist/litellm_enterprise-0.1.30.tar.gz b/enterprise/dist/litellm_enterprise-0.1.30.tar.gz
new file mode 100644
index 0000000000..2bb7510e5d
Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.30.tar.gz differ
diff --git a/enterprise/dist/litellm_enterprise-0.1.31-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.31-py3-none-any.whl
new file mode 100644
index 0000000000..03cadbd902
Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.31-py3-none-any.whl differ
diff --git a/enterprise/dist/litellm_enterprise-0.1.31.tar.gz b/enterprise/dist/litellm_enterprise-0.1.31.tar.gz
new file mode 100644
index 0000000000..1ba1a717f6
Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.31.tar.gz differ
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 61e0745bab..d3e0476930 100644
--- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py
+++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py
@@ -30,8 +30,15 @@ from litellm.integrations.email_templates.user_invitation_email import (
from litellm.integrations.email_templates.templates import (
MAX_BUDGET_ALERT_EMAIL_TEMPLATE,
SOFT_BUDGET_ALERT_EMAIL_TEMPLATE,
+ TEAM_SOFT_BUDGET_ALERT_EMAIL_TEMPLATE,
+)
+from litellm.proxy._types import (
+ CallInfo,
+ InvitationNew,
+ Litellm_EntityType,
+ UserAPIKeyAuth,
+ WebhookEvent,
)
-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
from litellm.constants import (
@@ -217,6 +224,78 @@ class BaseEmailLogger(CustomLogger):
)
pass
+ async def send_team_soft_budget_alert_email(self, event: WebhookEvent):
+ """
+ Send email to team members when team soft budget is crossed
+ Supports multiple recipients via alert_emails field from team metadata
+ """
+ # Collect all recipient emails
+ recipient_emails: List[str] = []
+
+ # Add additional alert emails from team metadata.soft_budget_alert_emails
+ if hasattr(event, "alert_emails") and event.alert_emails:
+ for email in event.alert_emails:
+ if email and email not in recipient_emails: # Avoid duplicates
+ recipient_emails.append(email)
+
+ # If no recipients found, skip sending
+ if not recipient_emails:
+ verbose_proxy_logger.warning(
+ f"No recipient emails found for team soft budget alert. event={event.model_dump(exclude_none=True)}"
+ )
+ return
+
+ # Validate that we have at least one valid email address
+ first_recipient_email = recipient_emails[0]
+ if not first_recipient_email or not first_recipient_email.strip():
+ verbose_proxy_logger.warning(
+ f"Invalid recipient email found for team soft budget alert. event={event.model_dump(exclude_none=True)}"
+ )
+ return
+
+ verbose_proxy_logger.debug(
+ f"send_team_soft_budget_alert_email_event: {json.dumps(event.model_dump(exclude_none=True), indent=4, default=str)}"
+ )
+
+ # Get email params using the first recipient email (for template formatting)
+ # For team alerts with alert_emails, we don't need user_id lookup since we already have email addresses
+ # Pass user_id=None to prevent _get_email_params from trying to look up email from a potentially None user_id
+ email_params = await self._get_email_params(
+ email_event=EmailEvent.soft_budget_crossed,
+ user_id=None, # Team alerts don't require user_id when alert_emails are provided
+ user_email=first_recipient_email,
+ event_message=event.event_message,
+ )
+
+ # 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}
"
+
+ # Use team alias or generic greeting
+ team_alias = event.team_alias or "Team"
+
+ email_html_content = TEAM_SOFT_BUDGET_ALERT_EMAIL_TEMPLATE.format(
+ email_logo_url=email_params.logo_url,
+ team_alias=team_alias,
+ 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,
+ )
+
+ # Send email to all recipients
+ await self.send_email(
+ from_email=self.DEFAULT_LITELLM_EMAIL,
+ to_email=recipient_emails,
+ subject=email_params.subject,
+ html_body=email_html_content,
+ )
+ pass
+
async def send_max_budget_alert_email(self, event: WebhookEvent):
"""
Send email to user when max budget alert threshold is reached
@@ -285,15 +364,36 @@ class BaseEmailLogger(CustomLogger):
# - 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":
+ # For team soft budget alerts, we only need team soft_budget to be set
+ # For other entity types, we need either max_budget or soft_budget
+ if user_info.event_group == Litellm_EntityType.TEAM:
+ if user_info.soft_budget is None:
+ return
+ # For team soft budget alerts, require alert_emails to be configured
+ # Team soft budget alerts are sent via metadata.soft_budget_alerting_emails
+ if user_info.alert_emails is None or len(user_info.alert_emails) == 0:
+ verbose_proxy_logger.debug(
+ "Skipping team soft budget email alert: no alert_emails configured",
+ )
+ return
+ else:
+ # For non-team alerts, require either max_budget or soft_budget
+ if user_info.max_budget is None and user_info.soft_budget is None:
+ return
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"
+ # Use appropriate ID based on event_group to ensure unique cache keys per entity type
+ if user_info.event_group == Litellm_EntityType.TEAM:
+ _id = user_info.team_id or "default_id"
+ elif user_info.event_group == Litellm_EntityType.ORGANIZATION:
+ _id = user_info.organization_id or "default_id"
+ elif user_info.event_group == Litellm_EntityType.USER:
+ _id = user_info.user_id or "default_id"
+ else:
+ # For KEY and other types, use token or user_id
+ _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
@@ -318,10 +418,15 @@ class BaseEmailLogger(CustomLogger):
projected_exceeded_date=user_info.projected_exceeded_date,
projected_spend=user_info.projected_spend,
event_group=user_info.event_group,
+ alert_emails=user_info.alert_emails,
)
try:
- await self.send_soft_budget_alert_email(webhook_event)
+ # Use team-specific function for team alerts, otherwise use standard function
+ if user_info.event_group == Litellm_EntityType.TEAM:
+ await self.send_team_soft_budget_alert_email(webhook_event)
+ else:
+ await self.send_soft_budget_alert_email(webhook_event)
# Cache the alert to prevent duplicate sends
await _cache.async_set_cache(
diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml
index c5aaa0a340..eca5cdb97d 100644
--- a/enterprise/pyproject.toml
+++ b/enterprise/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-enterprise"
-version = "0.1.29"
+version = "0.1.31"
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.29"
+version = "0.1.31"
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 5de23db0f2..091351df2b 100644
--- a/litellm/integrations/email_templates/templates.py
+++ b/litellm/integrations/email_templates/templates.py
@@ -85,6 +85,30 @@ SOFT_BUDGET_ALERT_EMAIL_TEMPLATE = """
The LiteLLM team
"""
+TEAM_SOFT_BUDGET_ALERT_EMAIL_TEMPLATE = """
+
+
+
Hi {team_alias} team member,
+
+ Your LiteLLM team 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
+"""
+
MAX_BUDGET_ALERT_EMAIL_TEMPLATE = """
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 5b697249a4..f38f94f4c9 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -2190,6 +2190,7 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken):
team_tpm_limit: Optional[int] = None
team_rpm_limit: Optional[int] = None
team_max_budget: Optional[float] = None
+ team_soft_budget: Optional[float] = None
team_models: List = []
team_blocked: bool = False
soft_budget: Optional[float] = None
@@ -2648,6 +2649,10 @@ class CallInfo(LiteLLMPydanticObjectBase):
projected_exceeded_date: Optional[str] = None
projected_spend: Optional[float] = None
event_group: Litellm_EntityType
+ alert_emails: Optional[List[str]] = Field(
+ default=None,
+ description="Additional email addresses to send alerts to (e.g., from team metadata)",
+ )
class WebhookEvent(CallInfo):
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 92e98d6446..c609317293 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -240,6 +240,13 @@ async def common_checks(
valid_token=valid_token,
)
+ # 3.0.5. If team is over soft budget (alert only, doesn't block)
+ await _team_soft_budget_check(
+ team_object=team_object,
+ proxy_logging_obj=proxy_logging_obj,
+ valid_token=valid_token,
+ )
+
# 3.1. If organization is in budget
await _organization_max_budget_check(
valid_token=valid_token,
@@ -2444,6 +2451,75 @@ async def _team_max_budget_check(
)
+async def _team_soft_budget_check(
+ team_object: Optional[LiteLLM_TeamTable],
+ valid_token: Optional[UserAPIKeyAuth],
+ proxy_logging_obj: ProxyLogging,
+):
+ """
+ Triggers a budget alert if the team is over it's soft budget.
+ """
+ if (
+ team_object is not None
+ and team_object.soft_budget is not None
+ and team_object.spend is not None
+ and team_object.spend >= team_object.soft_budget
+ ):
+ verbose_proxy_logger.debug(
+ "Crossed Soft Budget for team %s, spend %s, soft_budget %s",
+ team_object.team_id,
+ team_object.spend,
+ team_object.soft_budget,
+ )
+ if valid_token:
+ # Extract alert emails from team metadata
+ alert_emails: Optional[List[str]] = None
+ if team_object.metadata is not None and isinstance(team_object.metadata, dict):
+ soft_budget_alert_emails = team_object.metadata.get("soft_budget_alerting_emails")
+ if soft_budget_alert_emails is not None:
+ if isinstance(soft_budget_alert_emails, list):
+ alert_emails = [email for email in soft_budget_alert_emails if isinstance(email, str) and email.strip()]
+ elif isinstance(soft_budget_alert_emails, str):
+ # Handle comma-separated string
+ alert_emails = [email.strip() for email in soft_budget_alert_emails.split(",") if email.strip()]
+ # Filter out empty strings
+ if alert_emails:
+ alert_emails = [email for email in alert_emails if email]
+ else:
+ alert_emails = None
+
+ # Only send team soft budget alerts if alert_emails are configured
+ # Team soft budget alerts are sent via metadata.soft_budget_alerting_emails, not global alerting
+ if alert_emails is None or len(alert_emails) == 0:
+ verbose_proxy_logger.debug(
+ "Skipping team soft budget alert for team %s: no alert_emails configured in metadata.soft_budget_alerting_emails",
+ team_object.team_id,
+ )
+ return
+
+ call_info = CallInfo(
+ token=valid_token.token,
+ spend=team_object.spend,
+ max_budget=team_object.max_budget,
+ soft_budget=team_object.soft_budget,
+ user_id=valid_token.user_id,
+ team_id=valid_token.team_id,
+ team_alias=valid_token.team_alias,
+ organization_id=valid_token.org_id,
+ user_email=None, # Team-level alert, no specific user email
+ key_alias=valid_token.key_alias,
+ event_group=Litellm_EntityType.TEAM,
+ alert_emails=alert_emails,
+ )
+
+ asyncio.create_task(
+ proxy_logging_obj.budget_alerts(
+ type="soft_budget",
+ user_info=call_info,
+ )
+ )
+
+
async def _organization_max_budget_check(
valid_token: Optional[UserAPIKeyAuth],
team_object: Optional[LiteLLM_TeamTable],
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index a153c6e51c..05eeab3f61 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -1149,6 +1149,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
_team_obj: Optional[LiteLLM_TeamTable] = LiteLLM_TeamTable(
team_id=valid_token.team_id,
max_budget=valid_token.team_max_budget,
+ soft_budget=valid_token.team_soft_budget,
spend=valid_token.team_spend,
tpm_limit=valid_token.team_tpm_limit,
rpm_limit=valid_token.team_rpm_limit,
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index 6bbf0df74d..0aace65ff6 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -1370,17 +1370,36 @@ class ProxyLogging:
],
user_info: CallInfo,
):
- if self.alerting is None:
- # do nothing if alerting is not switched on
+ # For soft_budget alerts with alert_emails set, allow email sending even if alerting is None
+ # This enables team-specific soft budget email alerts via metadata.soft_budget_alerting_emails
+ # Note: user_info is a CallInfo that can represent user/team/org level info. For team budgets,
+ # alert_emails is populated from team_object.metadata.soft_budget_alerting_emails (see auth_checks.py)
+ is_soft_budget_with_alert_emails = (
+ type == "soft_budget"
+ and user_info.alert_emails is not None
+ and len(user_info.alert_emails) > 0
+ )
+
+ if self.alerting is None and not is_soft_budget_with_alert_emails:
+ # do nothing if alerting is not switched on (unless it's a soft_budget alert with team-specific emails)
return
- if "slack" in self.alerting:
- await self.slack_alerting_instance.budget_alerts(
- type=type,
- user_info=user_info,
- )
+ if self.alerting is not None and "slack" in self.alerting:
+ if self.slack_alerting_instance is not None:
+ 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:
+ # Call email_logging_instance if:
+ # 1. "email" is in alerting config, OR
+ # 2. It's a soft_budget alert with team-specific alert_emails (bypasses global alerting config)
+ should_send_email = (
+ (self.alerting is not None and "email" in self.alerting)
+ or is_soft_budget_with_alert_emails
+ )
+
+ if should_send_email and self.email_logging_instance is not None:
await self.email_logging_instance.budget_alerts(
type=type,
user_info=user_info,
@@ -2607,7 +2626,8 @@ class PrismaClient:
SELECT
v.*,
t.spend AS team_spend,
- t.max_budget AS team_max_budget,
+ t.max_budget AS team_max_budget,
+ t.soft_budget AS team_soft_budget,
t.tpm_limit AS team_tpm_limit,
t.rpm_limit AS team_rpm_limit,
t.models AS team_models,
diff --git a/pyproject.toml b/pyproject.toml
index 768a41d2e8..fe76d8e15d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "0.4.31", optional = true}
rich = {version = "13.7.1", optional = true}
-litellm-enterprise = {version = "0.1.27", optional = true}
+litellm-enterprise = {version = "0.1.31", 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 1fb8a22cc9..8b69d4ac85 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -73,4 +73,4 @@ pypdf>=6.6.2 # for PDF text extraction in RAG ingestion
########################
# LITELLM ENTERPRISE DEPENDENCIES
########################
-litellm-enterprise==0.1.29
+litellm-enterprise==0.1.31
diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py
index 05c6e4984a..66dfc8d15d 100644
--- a/tests/proxy_unit_tests/test_auth_checks.py
+++ b/tests/proxy_unit_tests/test_auth_checks.py
@@ -21,11 +21,13 @@ from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_UserTable,
LiteLLM_TeamTable,
+ Litellm_EntityType,
)
from litellm.proxy.utils import PrismaClient
from litellm.proxy.auth.auth_checks import (
can_team_access_model,
_virtual_key_soft_budget_check,
+ _team_soft_budget_check,
)
from litellm.proxy.utils import ProxyLogging
from litellm.proxy.utils import CallInfo
@@ -478,6 +480,84 @@ async def test_virtual_key_soft_budget_check(spend, soft_budget, expect_alert):
), f"Expected alert_triggered to be {expect_alert} for spend={spend}, soft_budget={soft_budget}"
+@pytest.mark.parametrize(
+ "spend, soft_budget, expect_alert, metadata, expected_alert_emails",
+ [
+ (100, 50, False, None, None), # Over soft budget, no metadata - no alert_emails configured, so no alert
+ (50, 50, False, None, None), # At soft budget, no metadata - no alert_emails configured, so no alert
+ (25, 50, False, None, None), # Under soft budget
+ (100, None, False, None, None), # No soft budget set
+ (100, 50, True, {"soft_budget_alerting_emails": ["team1@example.com", "team2@example.com"]}, ["team1@example.com", "team2@example.com"]), # Over soft budget with list of emails
+ (100, 50, True, {"soft_budget_alerting_emails": "team1@example.com,team2@example.com"}, ["team1@example.com", "team2@example.com"]), # Over soft budget with comma-separated emails
+ (100, 50, True, {"soft_budget_alerting_emails": ["team1@example.com", "", " ", "team2@example.com"]}, ["team1@example.com", "team2@example.com"]), # Over soft budget with empty strings filtered
+ ],
+)
+@pytest.mark.asyncio
+async def test_team_soft_budget_check(spend, soft_budget, expect_alert, metadata, expected_alert_emails):
+ """
+ Test cases for _team_soft_budget_check:
+ 1. Spend over soft budget, no alert_emails configured - should NOT trigger alert (alerts only sent when alert_emails configured)
+ 2. Spend at soft budget, no alert_emails configured - should NOT trigger alert (alerts only sent when alert_emails configured)
+ 3. Spend under soft budget - should not trigger alert
+ 4. No soft budget set - should not trigger alert
+ 5. Team with alert emails in metadata (list) - should include alert_emails in CallInfo
+ 6. Team with alert emails in metadata (comma-separated string) - should parse and include alert_emails
+ 7. Team with alert emails containing empty strings - should filter them out
+ """
+ alert_triggered = False
+ captured_call_info = None
+
+ class MockProxyLogging:
+ async def budget_alerts(self, type, user_info):
+ nonlocal alert_triggered, captured_call_info
+ alert_triggered = True
+ captured_call_info = user_info
+ assert type == "soft_budget"
+ assert isinstance(user_info, CallInfo)
+
+ valid_token = UserAPIKeyAuth(
+ token="test-token",
+ user_id="test-user",
+ team_id="test-team",
+ team_alias="test-team-alias",
+ key_alias="test-key",
+ )
+
+ team_object = LiteLLM_TeamTable(
+ team_id="test-team",
+ spend=spend,
+ soft_budget=soft_budget,
+ max_budget=100.0,
+ metadata=metadata,
+ )
+
+ proxy_logging_obj = MockProxyLogging()
+
+ await _team_soft_budget_check(
+ team_object=team_object,
+ valid_token=valid_token,
+ proxy_logging_obj=proxy_logging_obj,
+ )
+
+ await asyncio.sleep(0.1) # Allow time for the alert task to complete
+
+ assert (
+ alert_triggered == expect_alert
+ ), f"Expected alert_triggered to be {expect_alert} for spend={spend}, soft_budget={soft_budget}"
+
+ if expect_alert:
+ assert captured_call_info is not None
+ assert captured_call_info.team_id == "test-team"
+ assert captured_call_info.spend == spend
+ assert captured_call_info.soft_budget == soft_budget
+ assert captured_call_info.event_group == Litellm_EntityType.TEAM
+ # Verify alert_emails if expected
+ if expected_alert_emails is not None:
+ assert captured_call_info.alert_emails == expected_alert_emails
+ else:
+ assert captured_call_info.alert_emails is None or captured_call_info.alert_emails == []
+
+
@pytest.mark.asyncio
async def test_can_user_call_model():
from litellm.proxy.auth.auth_checks import can_user_call_model
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index c7803445eb..ee23811d5a 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -2870,6 +2870,111 @@ class TestProxyLoggingBudgetAlerts:
type=alert_type, user_info=user_info
)
+ async def test_budget_alerts_soft_budget_with_alert_emails_bypasses_alerting_none(self):
+ """
+ Test that soft_budget alerts with alert_emails bypass the alerting=None check
+ and send emails even when alerting is None.
+
+ This tests the new logic that allows team-specific soft budget email alerts
+ via metadata.soft_budget_alerting_emails to work even when global alerting is disabled.
+ """
+ from litellm.caching.caching import DualCache
+ from litellm.proxy.utils import ProxyLogging
+ from litellm.proxy._types import CallInfo, Litellm_EntityType
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ proxy_logging.alerting = None # Global alerting is disabled
+ proxy_logging.slack_alerting_instance = AsyncMock()
+ proxy_logging.email_logging_instance = AsyncMock()
+
+ # Create CallInfo with alert_emails set (simulating team metadata extraction)
+ user_info = CallInfo(
+ token="test-token",
+ spend=100.0,
+ soft_budget=50.0,
+ user_id="test-user",
+ team_id="test-team",
+ team_alias="test-team-alias",
+ event_group=Litellm_EntityType.TEAM,
+ alert_emails=["team1@example.com", "team2@example.com"],
+ )
+
+ # Should send email even though alerting is None (because of alert_emails)
+ await proxy_logging.budget_alerts(type="soft_budget", user_info=user_info)
+
+ # Verify slack was NOT called (alerting is None)
+ proxy_logging.slack_alerting_instance.budget_alerts.assert_not_called()
+
+ # Verify email WAS called (bypasses alerting=None check)
+ proxy_logging.email_logging_instance.budget_alerts.assert_called_once_with(
+ type="soft_budget", user_info=user_info
+ )
+
+ async def test_budget_alerts_soft_budget_without_alert_emails_respects_alerting_none(self):
+ """
+ Test that soft_budget alerts WITHOUT alert_emails still respect alerting=None
+ and do not send emails when alerting is None.
+ """
+ from litellm.caching.caching import DualCache
+ from litellm.proxy.utils import ProxyLogging
+ from litellm.proxy._types import CallInfo, Litellm_EntityType
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ proxy_logging.alerting = None
+ proxy_logging.slack_alerting_instance = AsyncMock()
+ proxy_logging.email_logging_instance = AsyncMock()
+
+ # Create CallInfo WITHOUT alert_emails
+ user_info = CallInfo(
+ token="test-token",
+ spend=100.0,
+ soft_budget=50.0,
+ user_id="test-user",
+ team_id="test-team",
+ team_alias="test-team-alias",
+ event_group=Litellm_EntityType.TEAM,
+ alert_emails=None, # No alert emails
+ )
+
+ # Should NOT send email (alerting is None and no alert_emails)
+ await proxy_logging.budget_alerts(type="soft_budget", user_info=user_info)
+
+ # Verify no calls were made
+ proxy_logging.slack_alerting_instance.budget_alerts.assert_not_called()
+ proxy_logging.email_logging_instance.budget_alerts.assert_not_called()
+
+ async def test_budget_alerts_soft_budget_with_empty_alert_emails_respects_alerting_none(self):
+ """
+ Test that soft_budget alerts with empty alert_emails list still respect alerting=None.
+ """
+ from litellm.caching.caching import DualCache
+ from litellm.proxy.utils import ProxyLogging
+ from litellm.proxy._types import CallInfo, Litellm_EntityType
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ proxy_logging.alerting = None
+ proxy_logging.slack_alerting_instance = AsyncMock()
+ proxy_logging.email_logging_instance = AsyncMock()
+
+ # Create CallInfo with empty alert_emails list
+ user_info = CallInfo(
+ token="test-token",
+ spend=100.0,
+ soft_budget=50.0,
+ user_id="test-user",
+ team_id="test-team",
+ team_alias="test-team-alias",
+ event_group=Litellm_EntityType.TEAM,
+ alert_emails=[], # Empty list
+ )
+
+ # Should NOT send email (alert_emails is empty)
+ await proxy_logging.budget_alerts(type="soft_budget", user_info=user_info)
+
+ # Verify no calls were made
+ proxy_logging.slack_alerting_instance.budget_alerts.assert_not_called()
+ proxy_logging.email_logging_instance.budget_alerts.assert_not_called()
+
def test_azure_ai_claude_provider_config():
"""Test that Azure AI Claude models return AzureAnthropicConfig for proper tool transformation."""