mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(logging): add structured budget fields to budget rejection failure logs (#33460)
This commit is contained in:
parent
74ff8d0ff9
commit
7fe3dd86a4
10 changed files with 152 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue