feat(logging): add structured budget fields to budget rejection failure logs (#33460)

This commit is contained in:
yucheng-berri 2026-07-16 12:39:04 -07:00 • committed by GitHub
parent 74ff8d0ff9
commit 7fe3dd86a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 152 additions and 4 deletions

View file

@ -966,11 +966,15 @@ class BudgetExceededError(Exception):
max_budget: float,
message: Optional[str] = None,
llm_provider: Optional[str] = None,
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.llm_provider = llm_provider or ""
self.entity_type = entity_type
self.entity_id = entity_id
# Surface unified rate-limit fields without joining the RateLimitError
# hierarchy so existing `except BudgetExceededError:` handlers keep
# working; custom callbacks reading StandardLoggingPayload pick these

View file

@ -38,6 +38,7 @@ from litellm import (
)
from litellm._logging import _is_debugging_on, _redact_string, verbose_logger
from litellm.exceptions import (
BudgetExceededError,
validate_rate_limit_category,
validate_rate_limit_type,
)
@ -4947,6 +4948,7 @@ class StandardLoggingPayloadSetup:
rate_limit_category = validate_rate_limit_category(getattr(original_exception, "category", None))
rate_limit_type = validate_rate_limit_type(getattr(original_exception, "rate_limit_type", None))
budget_error = original_exception if isinstance(original_exception, BudgetExceededError) else None
return StandardLoggingPayloadErrorInformation(
error_code=error_status,
@ -4956,6 +4958,10 @@ class StandardLoggingPayloadSetup:
error_message=error_message,
error_rate_limit_category=rate_limit_category,
error_rate_limit_type=rate_limit_type,
error_budget_entity_type=budget_error.entity_type if budget_error else None,
error_budget_entity_id=budget_error.entity_id if budget_error else None,
error_budget_limit=budget_error.max_budget if budget_error else None,
error_budget_spend=budget_error.current_cost if budget_error else None,
)
@staticmethod

View file

@ -361,7 +361,11 @@ def _global_proxy_budget_check(global_proxy_spend: Optional[float], skip_budget_
and route != "/models"
):
if math.isfinite(litellm.max_budget) and global_proxy_spend > litellm.max_budget:
raise litellm.BudgetExceededError(current_cost=global_proxy_spend, max_budget=litellm.max_budget)
raise litellm.BudgetExceededError(
current_cost=global_proxy_spend,
max_budget=litellm.max_budget,
entity_type=Litellm_EntityType.PROXY.value,
)
_GUARDRAIL_MODIFICATION_KEYS: tuple = (
@ -648,6 +652,8 @@ async def common_checks(
current_cost=user_spend,
max_budget=user_budget,
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
entity_type=Litellm_EntityType.USER.value,
entity_id=user_object.user_id,
)
# Each scope reads a distinct counter key with no cross-scope ordering
@ -1093,6 +1099,8 @@ async def _check_end_user_budget(
current_cost=end_user_spend,
max_budget=end_user_budget,
message=f"ExceededBudget: End User={end_user_obj.user_id} over budget. Spend={end_user_spend}, Budget={end_user_budget}",
entity_type=Litellm_EntityType.END_USER.value,
entity_id=end_user_obj.user_id,
)
@ -3552,6 +3560,8 @@ async def _virtual_key_max_budget_check(
current_cost=spend,
max_budget=valid_token.max_budget,
message=f"Budget has been exceeded! Key={key_descriptor} Current cost: {spend}, Max budget: {valid_token.max_budget}",
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
@ -3593,6 +3603,8 @@ async def _virtual_key_multi_budget_check(
f"ExceededBudget: Key over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
@ -3824,6 +3836,8 @@ async def _check_team_member_budget(
current_cost=team_member_spend,
max_budget=team_member_budget,
message=f"Budget has been exceeded! User={valid_token.user_id} in Team={team_object.team_id} Current cost: {team_member_spend}, Max budget: {team_member_budget}",
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{team_object.team_id}",
)
@ -3923,6 +3937,8 @@ async def _team_max_budget_check(
current_cost=spend,
max_budget=team_object.max_budget,
message=f"Budget has been exceeded! Team={team_object.team_id} Current cost: {spend}, Max budget: {team_object.max_budget}",
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
@ -3960,6 +3976,8 @@ async def _team_multi_budget_check(
f"ExceededBudget: Team={team_object.team_id} over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
@ -4081,6 +4099,8 @@ async def _project_max_budget_check(
current_cost=project_object.spend,
max_budget=max_budget,
message=f"Budget has been exceeded! Project={project_object.project_id} Current cost: {project_object.spend}, Max budget: {max_budget}",
entity_type=Litellm_EntityType.PROJECT.value,
entity_id=project_object.project_id,
)
@ -4269,6 +4289,8 @@ async def _organization_max_budget_check(
current_cost=org_spend,
max_budget=org_max_budget,
message=f"Budget has been exceeded! Organization={org_id} Current cost: {org_spend}, Max budget: {org_max_budget}",
entity_type=Litellm_EntityType.ORGANIZATION.value,
entity_id=org_id,
)
@ -4326,6 +4348,8 @@ async def _tag_max_budget_check(
current_cost=tag_spend,
max_budget=tag_object.litellm_budget_table.max_budget,
message=f"Budget has been exceeded! Tag={tag_name} Current cost: {tag_spend}, Max budget: {tag_object.litellm_budget_table.max_budget}",
entity_type=Litellm_EntityType.TAG.value,
entity_id=tag_name,
)

View file

@ -1797,6 +1797,8 @@ async def _user_api_key_auth_builder(
raise litellm.BudgetExceededError(
current_cost=team_member_spend,
max_budget=team_member_budget,
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{valid_token.team_id}",
)
# Check 3. If token is expired

View file

@ -5,7 +5,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
from litellm.integrations.custom_logger import Span
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import Litellm_EntityType, UserAPIKeyAuth
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
@ -76,6 +76,8 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
message=f"LiteLLM Virtual Key: {user_api_key_dict.token}, key_alias: {user_api_key_dict.key_alias}, exceeded budget for model={model}",
current_cost=_current_spend,
max_budget=_current_model_budget_info.max_budget,
entity_type=Litellm_EntityType.KEY.value,
entity_id=user_api_key_dict.token,
)
return True
@ -140,6 +142,8 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
message=f"LiteLLM End User: {end_user_id}, exceeded budget for model={model}",
current_cost=_current_spend,
max_budget=_current_model_budget_info.max_budget,
entity_type=Litellm_EntityType.END_USER.value,
entity_id=end_user_id,
)
return True

View file

@ -4,7 +4,7 @@ import asyncio
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Sequence, cast
from typing import Any, Dict, List, Mapping, Optional, Sequence, cast
import litellm
from litellm._logging import verbose_proxy_logger
@ -12,6 +12,7 @@ from litellm.caching import DualCache
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate
from litellm.proxy._types import (
Litellm_EntityType,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_UserTable,
@ -36,6 +37,17 @@ class _BudgetCounter:
window_start: Optional[datetime] = None
_COUNTER_ENTITY_TYPES: Mapping[str, str] = {
"Key": Litellm_EntityType.KEY.value,
"Team": Litellm_EntityType.TEAM.value,
"TeamMember": Litellm_EntityType.TEAM_MEMBER.value,
"User": Litellm_EntityType.USER.value,
"EndUser": Litellm_EntityType.END_USER.value,
"Tag": Litellm_EntityType.TAG.value,
"Organization": Litellm_EntityType.ORGANIZATION.value,
}
class _CounterReservationUnavailable(Exception):
def __init__(
self,
@ -108,6 +120,8 @@ async def _apply_over_budget_reservation_policy(
f"Current cost: {current_spend}, "
f"Max budget: {counter.max_budget}"
),
entity_type=_COUNTER_ENTITY_TYPES.get(counter.entity_type),
entity_id=counter.spend_log_entity_id or counter.entity_id,
)

View file

@ -2697,6 +2697,10 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
# hints). Lets dashboards split rate-limit failures by cause without
# parsing free-text error messages.
error_rate_limit_type: Optional[str]
error_budget_entity_type: Optional[str]
error_budget_entity_id: Optional[str]
error_budget_limit: Optional[float]
error_budget_spend: Optional[float]
class GuardrailMode(TypedDict, total=False):

View file

@ -2247,6 +2247,53 @@ def test_get_error_information_prefers_message_attribute_over_str():
assert result["error_class"] == "ProxyExceptionLike"
def test_get_error_information_budget_exceeded_structured_fields():
"""
Regression for LIT-4458: a budget-rejected request's failure
StandardLoggingPayload must identify WHICH budget blocked the call
as structured fields, not only inside the free-text error_str
("ExceededBudget: User=... over budget. Spend=..., Budget=...").
Asserts get_error_information copies entity_type / entity_id /
max_budget / current_cost off BudgetExceededError into
error_budget_entity_type / error_budget_entity_id /
error_budget_limit / error_budget_spend, and leaves all four None
for non-budget exceptions.
"""
from litellm.exceptions import BudgetExceededError
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
exc = BudgetExceededError(
current_cost=3.4e-05,
max_budget=1e-06,
message="ExceededBudget: User=repro-user over budget. Spend=3.4e-05, Budget=1e-06",
entity_type="user",
entity_id="repro-user",
)
result = StandardLoggingPayloadSetup.get_error_information(exc)
assert result["error_budget_entity_type"] == "user"
assert result["error_budget_entity_id"] == "repro-user"
assert result["error_budget_limit"] == 1e-06
assert result["error_budget_spend"] == 3.4e-05
assert result["error_code"] == "429"
assert result["error_class"] == "BudgetExceededError"
assert result["error_rate_limit_type"] == "budget"
legacy_exc = BudgetExceededError(current_cost=2.0, max_budget=1.0)
legacy_result = StandardLoggingPayloadSetup.get_error_information(legacy_exc)
assert legacy_result["error_budget_entity_type"] is None
assert legacy_result["error_budget_entity_id"] is None
assert legacy_result["error_budget_limit"] == 1.0
assert legacy_result["error_budget_spend"] == 2.0
non_budget_result = StandardLoggingPayloadSetup.get_error_information(ValueError("boom"))
assert non_budget_result["error_budget_entity_type"] is None
assert non_budget_result["error_budget_entity_id"] is None
assert non_budget_result["error_budget_limit"] is None
assert non_budget_result["error_budget_spend"] is None
def test_get_error_information_preserves_explicit_empty_message():
"""
An exception that deliberately sets `.message = ""` must surface

View file

@ -2634,6 +2634,8 @@ async def test_virtual_key_budget_check_reads_from_spend_counter():
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.max_budget == 1.0
assert exc_info.value.entity_type == "key"
assert exc_info.value.entity_id == "test-hashed-token"
@pytest.mark.asyncio
@ -2861,6 +2863,8 @@ async def test_team_budget_check_reads_from_spend_counter():
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.entity_type == "team"
assert exc_info.value.entity_id == "test-team"
@pytest.mark.asyncio
@ -2888,6 +2892,8 @@ async def test_end_user_budget_check_reads_from_spend_counter():
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.max_budget == 1.0
assert exc_info.value.entity_type == "end_user"
assert exc_info.value.entity_id == "customer-1"
@pytest.mark.asyncio
@ -2926,6 +2932,8 @@ async def test_tag_budget_check_reads_from_spend_counter():
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.max_budget == 1.0
assert exc_info.value.entity_type == "tag"
assert exc_info.value.entity_id == "paid-tag"
@pytest.mark.asyncio
@ -2976,6 +2984,8 @@ async def test_team_member_budget_check_reads_from_spend_counter():
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.entity_type == "team_member"
assert exc_info.value.entity_id == "test-user:test-team"
class TestGuardrailModificationCheck:

View file

@ -150,8 +150,41 @@ async def test_reservation_blocks_over_budget_non_throttled_key(
await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj)
await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) # counter -> 1.0
with pytest.raises(litellm.BudgetExceededError):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj)
assert exc_info.value.entity_type == "key"
assert exc_info.value.entity_id == "key-no-optin-over"
@pytest.mark.asyncio
async def test_over_budget_window_counter_tags_clean_entity_id():
from litellm.proxy.spend_tracking.budget_reservation import (
_apply_over_budget_reservation_policy,
_BudgetCounter,
)
counter = _BudgetCounter(
counter_key="spend:key:test-token:window:1d",
max_budget=1.0,
fallback_spend=0.0,
entity_type="Key",
entity_id="test-token:1d",
spend_log_entity_id="test-token",
)
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _apply_over_budget_reservation_policy(
counter=counter,
valid_token=None,
entry={"counter_key": counter.counter_key},
applied_entries=[],
reservation_cost=0.5,
current_spend=2.0,
)
assert exc_info.value.entity_type == "key"
assert exc_info.value.entity_id == "test-token"
assert exc_info.value.max_budget == 1.0
assert exc_info.value.current_cost == 2.0
def test_should_not_serialize_budget_reservation_on_user_api_key_auth():