Skip to content

Commit 6dcdb86

Browse files
feat(slack_alerting): add spend_report_include_tags to hide tag breakdown in spend reports
1 parent 02cba40 commit 6dcdb86

6 files changed

Lines changed: 142 additions & 20 deletions

File tree

litellm/integrations/SlackAlerting/slack_alerting.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,7 +1809,7 @@ async def send_weekly_spend_report(
18091809
_team_spend = round(float(spend["total_spend"]), 4)
18101810
_spend_message += f"Team: `{spend['team_alias']}` | Spend: `${_team_spend}`\n"
18111811

1812-
if spend_per_tag is not None:
1812+
if spend_per_tag is not None and self.alerting_args.spend_report_include_tags:
18131813
_spend_message += "\n*Tag Spend Report:*\n"
18141814
for spend in spend_per_tag:
18151815
_tag_spend = round(float(spend["total_spend"]), 4)
@@ -1872,7 +1872,7 @@ async def send_monthly_spend_report(self):
18721872
_team_spend = round(_team_spend, 4)
18731873
_spend_message += f"Team: `{spend['team_alias']}` | Spend: `${_team_spend}`\n"
18741874

1875-
if monthly_spend_per_tag is not None:
1875+
if monthly_spend_per_tag is not None and self.alerting_args.spend_report_include_tags:
18761876
_spend_message += "\n*Tag Spend Report:*\n"
18771877
for spend in monthly_spend_per_tag:
18781878
_tag_spend = spend["total_spend"]

litellm/proxy/proxy_server.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14468,6 +14468,22 @@ async def model_settings():
1446814468
#### ALERTING MANAGEMENT ENDPOINTS ####
1446914469

1447014470

14471+
_ALERTING_SETTINGS_FIELD_TYPES: Final[Mapping[str, str]] = MappingProxyType(
14472+
{
14473+
"slack_alerting": "Boolean",
14474+
"daily_report_frequency": "Integer",
14475+
"report_check_interval": "Integer",
14476+
"budget_alert_ttl": "Integer",
14477+
"outage_alert_ttl": "Integer",
14478+
"region_outage_alert_ttl": "Integer",
14479+
"minor_outage_alert_threshold": "Integer",
14480+
"major_outage_alert_threshold": "Integer",
14481+
"max_outage_alert_list_size": "Integer",
14482+
"spend_report_include_tags": "Boolean",
14483+
}
14484+
)
14485+
14486+
1447114487
@router.get(
1447214488
"/alerting/settings",
1447314489
description="Return the configurable alerting param, description, and current value",
@@ -14483,7 +14499,7 @@ async def alerting_settings(
1448314499
Used by UI to generate 'alerting settings' page
1448414500
{
1448514501
field_name=field_name,
14486-
field_type=allowed_args[field_name]["type"], # string/int
14502+
field_type=allowed_args[field_name], # string/int
1448714503
field_description=field_info.description or "", # human-friendly description
1448814504
field_value=general_settings.get(field_name, None), # example value
1448914505
}
@@ -14513,17 +14529,7 @@ async def alerting_settings(
1451314529
alerting_args_dict = {}
1451414530
alerting_values = None
1451514531

14516-
allowed_args: Final = {
14517-
"slack_alerting": {"type": "Boolean"},
14518-
"daily_report_frequency": {"type": "Integer"},
14519-
"report_check_interval": {"type": "Integer"},
14520-
"budget_alert_ttl": {"type": "Integer"},
14521-
"outage_alert_ttl": {"type": "Integer"},
14522-
"region_outage_alert_ttl": {"type": "Integer"},
14523-
"minor_outage_alert_threshold": {"type": "Integer"},
14524-
"major_outage_alert_threshold": {"type": "Integer"},
14525-
"max_outage_alert_list_size": {"type": "Integer"},
14526-
}
14532+
allowed_args: Final = _ALERTING_SETTINGS_FIELD_TYPES
1452714533

1452814534
_slack_alerting: Final[SlackAlerting] = proxy_logging_obj.slack_alerting_instance
1452914535
_slack_alerting_args_dict: Final = _slack_alerting.alerting_args.model_dump()
@@ -14538,7 +14544,7 @@ async def alerting_settings(
1453814544

1453914545
_response_obj = ConfigList(
1454014546
field_name="slack_alerting",
14541-
field_type=allowed_args["slack_alerting"]["type"],
14547+
field_type=allowed_args["slack_alerting"],
1454214548
field_description="Enable slack alerting for monitoring proxy in production: llm outages, budgets, spend tracking failures.",
1454314549
field_value=is_slack_enabled,
1454414550
stored_in_db=True if alerting_values is not None else False,
@@ -14557,7 +14563,7 @@ async def alerting_settings(
1455714563

1455814564
_response_obj = ConfigList(
1455914565
field_name=field_name,
14560-
field_type=allowed_args[field_name]["type"],
14566+
field_type=allowed_args[field_name],
1456114567
field_description=field_info.description or "",
1456214568
field_value=_slack_alerting_args_dict.get(field_name, None),
1456314569
stored_in_db=_stored_in_db,

litellm/types/integrations/slack_alerting.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ class SlackAlertingArgs(LiteLLMPydanticObjectBase):
9191
default=False,
9292
description="If true, the alerting payload will be printed to the console.",
9393
)
94+
spend_report_include_tags: bool = Field(
95+
default=True,
96+
description="If false, spend reports drop the per-tag breakdown and keep the per-team one. Tags stay tracked.",
97+
)
9498

9599

96100
class DeploymentMetrics(LiteLLMPydanticObjectBase):

tests/test_litellm/integrations/SlackAlerting/test_slack_alerting.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import json
44
import time
55
import unittest
6-
from typing import Final, List, Optional, Tuple
6+
from collections.abc import Mapping
7+
from types import MappingProxyType
8+
from typing import Final, List, Literal, Optional, Tuple
79
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
810

911
import pytest
@@ -12,7 +14,7 @@
1214
from litellm.caching.caching import DualCache
1315
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
1416
from litellm.proxy._types import CallInfo, Litellm_EntityType
15-
from litellm.types.integrations.slack_alerting import SlackAlertingCacheKeys
17+
from litellm.types.integrations.slack_alerting import AlertType, SlackAlertingCacheKeys
1618

1719

1820
class TestSlackAlerting(unittest.TestCase):
@@ -366,3 +368,68 @@ async def test_scheduled_daily_report_threads_the_pod_lock_manager_through():
366368

367369
_, kwargs = slack_alerting._run_scheduler_helper.await_args
368370
assert kwargs["pod_lock_manager"] is pod_lock_manager
371+
372+
373+
_SPEND_PER_TEAM: Final = (MappingProxyType({"team_alias": "eng", "total_spend": 12.3456789}),)
374+
_SPEND_PER_TAG: Final = (MappingProxyType({"individual_request_tag": "prod", "total_spend": 4.2}),)
375+
_SPEND_REPORT_WEBHOOK: Final = "https://hooks.slack.example/spend-report"
376+
_SPEND_REPORT_BATCH_SIZE: Final = 2 # pins the flush threshold above 1 so DEFAULT_BATCH_SIZE can't trigger a real POST
377+
378+
379+
async def _delivered_spend_report(
380+
monkeypatch: pytest.MonkeyPatch,
381+
alerting_args: Mapping[str, bool],
382+
report_type: Literal["weekly", "monthly"],
383+
) -> tuple[str, AsyncMock]:
384+
monkeypatch.delenv("PROXY_BASE_URL", raising=False) # send_alert appends it to the payload
385+
slack_alerting: Final = SlackAlerting(
386+
alerting=["slack"],
387+
alert_types=[AlertType.spend_reports],
388+
internal_usage_cache=DualCache(),
389+
alerting_args=alerting_args,
390+
default_webhook_url=_SPEND_REPORT_WEBHOOK,
391+
batch_size=_SPEND_REPORT_BATCH_SIZE,
392+
)
393+
slack_alerting.periodic_started = True # keeps send_alert from spawning an unawaited flush task
394+
get_report: Final = AsyncMock(return_value=(_SPEND_PER_TEAM, _SPEND_PER_TAG))
395+
with patch( # test-quality-ok: lazily imported module function, no injection seam; the boundary is the DB
396+
"litellm.proxy.spend_tracking.spend_management_endpoints._get_spend_report_for_time_range",
397+
new=get_report,
398+
):
399+
if report_type == "weekly":
400+
await slack_alerting.send_weekly_spend_report()
401+
else:
402+
await slack_alerting.send_monthly_spend_report()
403+
404+
assert len(slack_alerting.log_queue) == 1
405+
assert slack_alerting.log_queue[0]["url"] == _SPEND_REPORT_WEBHOOK
406+
return slack_alerting.log_queue[0]["payload"]["text"], get_report
407+
408+
409+
@pytest.mark.parametrize("report_type", ("weekly", "monthly"))
410+
@pytest.mark.parametrize("alerting_args", (MappingProxyType({}), MappingProxyType({"spend_report_include_tags": True})))
411+
@pytest.mark.asyncio
412+
async def test_spend_report_includes_tag_breakdown_by_default(
413+
monkeypatch: pytest.MonkeyPatch, report_type: Literal["weekly", "monthly"], alerting_args: Mapping[str, bool]
414+
) -> None:
415+
message, _ = await _delivered_spend_report(monkeypatch, alerting_args, report_type)
416+
417+
assert "*Team Spend Report:*" in message
418+
assert "Team: `eng` | Spend: `$12.3457`" in message
419+
assert "*Tag Spend Report:*" in message
420+
assert "Tag: `prod` | Spend: `$4.2`" in message
421+
422+
423+
@pytest.mark.parametrize("report_type", ("weekly", "monthly"))
424+
@pytest.mark.asyncio
425+
async def test_spend_report_omits_tag_breakdown_when_disabled(
426+
monkeypatch: pytest.MonkeyPatch, report_type: Literal["weekly", "monthly"]
427+
) -> None:
428+
message, get_report = await _delivered_spend_report(
429+
monkeypatch, MappingProxyType({"spend_report_include_tags": False}), report_type
430+
)
431+
432+
assert "*Team Spend Report:*" in message
433+
assert "Team: `eng` | Spend: `$12.3457`" in message
434+
assert "Tag" not in message
435+
get_report.assert_awaited_once()

tests/test_litellm/proxy/test_proxy_server.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9395,6 +9395,51 @@ def test_get_config_list_includes_apply_user_budget_to_team_keys(monkeypatch):
93959395
app.dependency_overrides.clear()
93969396

93979397

9398+
def test_alerting_settings_exposes_spend_report_include_tags(monkeypatch: pytest.MonkeyPatch) -> None:
9399+
"""The Admin UI form rebuilds alerting_args from exactly the fields /alerting/settings returns,
9400+
and /config/field/update replaces the whole blob, so a field missing from allowed_args is
9401+
silently reset to its default the next time anyone saves that form."""
9402+
import types
9403+
from unittest.mock import AsyncMock, MagicMock
9404+
9405+
from fastapi.testclient import TestClient
9406+
9407+
import litellm.proxy.proxy_server as ps
9408+
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
9409+
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
9410+
from litellm.proxy.proxy_server import app
9411+
9412+
mock_prisma = MagicMock()
9413+
mock_config_table = MagicMock()
9414+
mock_config_table.find_first = AsyncMock(
9415+
return_value=types.SimpleNamespace(param_value={"alerting_args": {"spend_report_include_tags": False}})
9416+
)
9417+
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
9418+
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
9419+
monkeypatch.setattr(
9420+
ps,
9421+
"proxy_logging_obj",
9422+
types.SimpleNamespace(
9423+
slack_alerting_instance=SlackAlerting(alerting_args={"spend_report_include_tags": False})
9424+
),
9425+
)
9426+
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
9427+
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
9428+
)
9429+
try:
9430+
client = TestClient(app)
9431+
resp = client.get("/alerting/settings")
9432+
assert resp.status_code == 200, resp.text
9433+
fields = {item["field_name"]: item for item in resp.json()}
9434+
assert "spend_report_include_tags" in fields
9435+
assert fields["spend_report_include_tags"]["field_type"] == "Boolean"
9436+
assert fields["spend_report_include_tags"]["field_value"] is False
9437+
assert fields["spend_report_include_tags"]["field_default_value"] is True
9438+
assert fields["spend_report_include_tags"]["stored_in_db"] is True
9439+
finally:
9440+
app.dependency_overrides.clear()
9441+
9442+
93989443
def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch):
93999444
"""The throttle fraction is a litellm_settings scalar surfaced on the General
94009445
Settings table as a Float field so it sits with the other global limits; it

type-discipline-budget.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"LIT001": {
3-
"limit": 22804
3+
"limit": 22803
44
},
55
"LIT002": {
6-
"limit": 26872
6+
"limit": 26864
77
},
88
"LIT003": {
99
"limit": 269

0 commit comments

Comments
 (0)