From b1fd01c0a1ee2a19c421001889d7c0a615b966d5 Mon Sep 17 00:00:00 2001 From: Yuri Shterenberg Date: Sun, 9 Aug 2026 18:02:44 +0300 Subject: [PATCH] fix: don't raise KeyError: 'token' on budget alerts for JWT-auth requests `SlackAlerting._get_user_info_str()` builds `user_info.model_dump(exclude_none=True)` and then pops `"token"` unconditionally. `CallInfo.token` is `Optional[str]` with a `None` default, so `exclude_none=True` drops the key whenever it is unset and the pop raises `KeyError: 'token'`. It is unset for JWT-authenticated requests: that auth path builds `UserAPIKeyAuth(api_key=None, ...)` with no `token`, so a team soft-budget alert for a JWT caller reaches the formatter with `token=None`. The blast radius is wider than a missing Slack message, because `ProxyLogging.budget_alerts()` awaits the Slack call first and does not wrap it: 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(...) # <- raises here ... if should_send_email and self.email_logging_instance is not None: await self.email_logging_instance.budget_alerts(...) # <- never reached So the KeyError propagates out of `budget_alerts()` and takes the *email* alert down with it. On a proxy with `enable_jwt_auth: true` and a team over its `soft_budget`, this meant budget alert emails were never attempted at all - no SMTP call, no log line to explain it. It also raises before the dedupe-cache check, so it recurs on every request from an over-budget team rather than once per `budget_alert_ttl`: we observed 2375 of these in ~5 days on one proxy. Fixed by making the pop tolerate the field's documented absence. Adds tests covering both the unset case (which fails with `KeyError: 'token'` before this change) and the set case, asserting the token value stays out of the rendered alert either way. --- .../SlackAlerting/slack_alerting.py | 5 +- .../test_slack_alerting_user_info_str.py | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_user_info_str.py diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index 771d7876fea..a6fd7380542 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -630,7 +630,10 @@ class SlackAlerting(CustomBatchLogger): Create a standard message for a budget alert """ _all_fields_as_dict: Final = user_info.model_dump(exclude_none=True) - _all_fields_as_dict.pop("token") + # CallInfo.token is Optional and exclude_none=True drops it when unset, so + # this must tolerate its absence. It is unset for JWT-authenticated + # requests, where UserAPIKeyAuth is built without a token. + _all_fields_as_dict.pop("token", None) msg = "" for k, v in _all_fields_as_dict.items(): if isinstance(v, Litellm_EntityType): diff --git a/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_user_info_str.py b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_user_info_str.py new file mode 100644 index 00000000000..862dd96669c --- /dev/null +++ b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_user_info_str.py @@ -0,0 +1,60 @@ +import os +import sys + +import pytest + +# Adds the grandparent directory to sys.path to allow importing project modules +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting +from litellm.proxy._types import CallInfo, Litellm_EntityType + + +def test_get_user_info_str_without_token(): + """ + CallInfo.token is Optional and model_dump(exclude_none=True) drops it when + unset, so _get_user_info_str must not assume the key is present. + + It is unset for JWT-authenticated requests: that auth path builds + UserAPIKeyAuth without a token, so a team soft-budget alert for a JWT caller + reaches here with token=None. Popping it unconditionally raised + KeyError: 'token', which propagated out of ProxyLogging.budget_alerts() and + aborted the alert - including the email alert dispatched after the Slack one. + """ + slack_alerting = SlackAlerting() + + user_info = CallInfo( + spend=25.0, + soft_budget=10.0, + team_id="my-team", + team_alias="My Team", + event_group=Litellm_EntityType.TEAM, + ) + + msg = slack_alerting._get_user_info_str(user_info) + + assert "token" not in msg + assert "*spend:* `25.0`" in msg + assert "*soft_budget:* `10.0`" in msg + assert "*team_id:* `my-team`" in msg + # Litellm_EntityType is rendered as its value, not the enum repr + assert "*event_group:* `team`" in msg + + +def test_get_user_info_str_omits_token_when_present(): + """A token, when set, is still excluded from the alert message.""" + slack_alerting = SlackAlerting() + + user_info = CallInfo( + spend=5.0, + max_budget=10.0, + token="sk-hashed-token-value", + user_id="my-user", + event_group=Litellm_EntityType.KEY, + ) + + msg = slack_alerting._get_user_info_str(user_info) + + assert "sk-hashed-token-value" not in msg + assert "token" not in msg + assert "*user_id:* `my-user`" in msg