fix(budgets): block team, end-user, tag, and model budgets at the exact limit

This commit is contained in:
Devin AI 2026-07-16 15:41:33 +00:00
parent 69a491e168
commit 0aacd8a9e3
4 changed files with 121 additions and 5 deletions

View file

@ -1088,7 +1088,7 @@ async def _check_end_user_budget(
max_budget=end_user_budget,
fallback_authoritative=True,
)
if end_user_spend > end_user_budget:
if end_user_spend >= end_user_budget:
raise litellm.BudgetExceededError(
current_cost=end_user_spend,
max_budget=end_user_budget,
@ -3900,7 +3900,7 @@ async def _team_max_budget_check(
max_budget=team_object.max_budget,
)
if math.isfinite(team_object.max_budget) and spend > team_object.max_budget:
if math.isfinite(team_object.max_budget) and spend >= team_object.max_budget:
if valid_token:
call_info = CallInfo(
token=valid_token.token,
@ -4320,7 +4320,7 @@ async def _tag_max_budget_check(
max_budget=tag_object.litellm_budget_table.max_budget,
fallback_authoritative=True,
)
if tag_spend <= tag_object.litellm_budget_table.max_budget:
if tag_spend < tag_object.litellm_budget_table.max_budget:
continue
raise litellm.BudgetExceededError(
current_cost=tag_spend,

View file

@ -70,7 +70,7 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
if (
_current_spend is not None
and _current_model_budget_info.max_budget is not None
and _current_spend > _current_model_budget_info.max_budget
and _current_spend >= _current_model_budget_info.max_budget
):
raise litellm.BudgetExceededError(
message=f"LiteLLM Virtual Key: {user_api_key_dict.token}, key_alias: {user_api_key_dict.key_alias}, exceeded budget for model={model}",
@ -134,7 +134,7 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
if (
_current_spend is not None
and _current_model_budget_info.max_budget is not None
and _current_spend > _current_model_budget_info.max_budget
and _current_spend >= _current_model_budget_info.max_budget
):
raise litellm.BudgetExceededError(
message=f"LiteLLM End User: {end_user_id}, exceeded budget for model={model}",

View file

@ -87,6 +87,13 @@ async def test_is_key_within_model_budget(budget_limiter):
with pytest.raises(litellm.BudgetExceededError):
await budget_limiter.is_key_within_model_budget(user_api_key, "gpt-4")
# Spend equal to the limit must block (>=, not >)
with patch.object(
budget_limiter, "_get_virtual_key_spend_for_model", return_value=100.0
):
with pytest.raises(litellm.BudgetExceededError):
await budget_limiter.is_key_within_model_budget(user_api_key, "gpt-4")
# Test model not in budget config
assert (
await budget_limiter.is_key_within_model_budget(user_api_key, "non-existent")
@ -187,6 +194,17 @@ async def test_is_end_user_within_model_budget(budget_limiter):
"gpt-4",
)
# Spend equal to the limit must block (>=, not >)
with patch.object(
budget_limiter, "_get_end_user_spend_for_model", return_value=100.0
):
with pytest.raises(litellm.BudgetExceededError):
await budget_limiter.is_end_user_within_model_budget(
"test-user",
{"gpt-4": {"budget_limit": 100.0, "time_period": "1d"}},
"gpt-4",
)
# Test model not in budget config
assert (
await budget_limiter.is_end_user_within_model_budget(

View file

@ -2928,6 +2928,104 @@ async def test_tag_budget_check_reads_from_spend_counter():
assert exc_info.value.max_budget == 1.0
@pytest.mark.asyncio
async def test_team_budget_check_blocks_at_exact_limit():
"""Team hard budget must block when spend equals max_budget (>=, not >)."""
from litellm.proxy.utils import ProxyLogging
team_object = LiteLLM_TeamTable(
team_id="test-team",
spend=0.0,
max_budget=10.0,
)
valid_token = UserAPIKeyAuth(token="test-token", team_id="test-team")
proxy_logging_obj = ProxyLogging(user_api_key_cache=None)
proxy_logging_obj.budget_alerts = AsyncMock()
async def mock_get_current_spend(
counter_key, fallback_spend, max_budget=None, **kwargs
):
if counter_key == "spend:team:test-team":
return 10.0
return fallback_spend
with patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _team_max_budget_check(
team_object=team_object,
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.current_cost == 10.0
assert exc_info.value.max_budget == 10.0
@pytest.mark.asyncio
async def test_end_user_budget_check_blocks_at_exact_limit():
"""End-user hard budget must block when spend equals max_budget (>=, not >)."""
end_user_object = LiteLLM_EndUserTable(
user_id="customer-1",
blocked=False,
spend=0.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=10.0),
)
async def mock_get_current_spend(
counter_key, fallback_spend, max_budget=None, **kwargs
):
if counter_key == "spend:end_user:customer-1":
return 10.0
return fallback_spend
with patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _check_end_user_budget(
end_user_obj=end_user_object,
route="/chat/completions",
)
assert exc_info.value.current_cost == 10.0
assert exc_info.value.max_budget == 10.0
@pytest.mark.asyncio
async def test_tag_budget_check_blocks_at_exact_limit():
"""Tag hard budget must block when spend equals max_budget (>=, not >)."""
from litellm.proxy.utils import ProxyLogging
tag_object = LiteLLM_TagTable(
tag_name="paid-tag",
spend=0.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=10.0),
)
async def mock_get_current_spend(
counter_key, fallback_spend, max_budget=None, **kwargs
):
if counter_key == "spend:tag:paid-tag":
return 10.0
return fallback_spend
with (
patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend),
patch(
"litellm.proxy.auth.auth_checks.get_tag_objects_batch",
new_callable=AsyncMock,
return_value={"paid-tag": tag_object},
),
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _tag_max_budget_check(
request_body={"metadata": {"tags": ["paid-tag"]}},
prisma_client=MagicMock(),
user_api_key_cache=MagicMock(),
proxy_logging_obj=ProxyLogging(user_api_key_cache=None),
valid_token=UserAPIKeyAuth(token="test-token"),
)
assert exc_info.value.current_cost == 10.0
assert exc_info.value.max_budget == 10.0
@pytest.mark.asyncio
async def test_team_member_budget_check_reads_from_spend_counter():
"""Team member budget check should use get_current_spend when counter exists."""