mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(proxy)!: enforce user budget on team keys (read-time + reservation) with UI opt-out (#32005)
* fix: enforce user budget on team keys User budget was skipped when the key belonged to a team, letting users exceed their personal budget by going through a team key. Remove the team_object guard in _user_max_budget_check so user budgets are always enforced. Add skip_user_budget_on_team_key general_settings flag to opt back into the old behavior. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: update test to expect user budget enforcement on team keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): enforce user budget on team keys in reservation path and expose skip flag in UI Extends the read-time fix so the optimistic budget reservation also reserves the user spend counter for team-scoped keys, register skip_user_budget_on_team_key in ConfigGeneralSettings so /config/field/update accepts it, and surface it as a Boolean toggle on the Admin UI General Settings table via allowed_args in /config/list. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: assert budget_exceeded ProxyException in personal budget test Tighten the broad pytest.raises(Exception) so the test only passes when the auth flow rejects with a budget_exceeded ProxyException, and switch the new ConfigGeneralSettings field to Optional[bool] to match the surrounding annotation style * fix: revert to bool | None to stay under UP045 strict budget --------- Co-authored-by: Krrish Dholakia <krrishdholakia@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
parent
939117bb8d
commit
ffe0c4c185
10 changed files with 243 additions and 28 deletions
|
|
@ -2422,6 +2422,16 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
"is active as a reminder that hard enforcement is relaxed."
|
||||
),
|
||||
)
|
||||
skip_user_budget_on_team_key: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"If True, restores the legacy behavior where a user's personal "
|
||||
"max_budget is NOT enforced when their key belongs to a team; only "
|
||||
"the team (and team-member) budgets apply. Defaults to False, meaning "
|
||||
"the user's personal max_budget is always enforced regardless of "
|
||||
"whether the key belongs to a team (see GitHub issue #12905)."
|
||||
),
|
||||
)
|
||||
user_url_validation: Optional[bool] = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -626,26 +626,29 @@ async def common_checks(
|
|||
)
|
||||
|
||||
async def _user_max_budget_check() -> None:
|
||||
# 4.1 personal budget, if personal key
|
||||
if (
|
||||
(team_object is None or team_object.team_id is None)
|
||||
and user_object is not None
|
||||
and user_object.max_budget is not None
|
||||
):
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
if user_object is None or user_object.max_budget is None:
|
||||
return
|
||||
skip_for_team = (
|
||||
general_settings.get("skip_user_budget_on_team_key") is True
|
||||
and team_object is not None
|
||||
and team_object.team_id is not None
|
||||
)
|
||||
if skip_for_team:
|
||||
return
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
user_budget = user_object.max_budget
|
||||
user_spend = await get_current_spend(
|
||||
counter_key=f"spend:user:{user_object.user_id}",
|
||||
fallback_spend=user_object.spend or 0.0,
|
||||
user_budget = user_object.max_budget
|
||||
user_spend = await get_current_spend(
|
||||
counter_key=f"spend:user:{user_object.user_id}",
|
||||
fallback_spend=user_object.spend or 0.0,
|
||||
max_budget=user_budget,
|
||||
)
|
||||
if math.isfinite(user_budget) and user_spend >= user_budget:
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=user_spend,
|
||||
max_budget=user_budget,
|
||||
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
|
||||
)
|
||||
if math.isfinite(user_budget) and user_spend >= user_budget:
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=user_spend,
|
||||
max_budget=user_budget,
|
||||
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
|
||||
)
|
||||
|
||||
# Each scope reads a distinct counter key with no cross-scope ordering
|
||||
# dependency, so the per-scope Redis-first reads run concurrently instead
|
||||
|
|
|
|||
|
|
@ -2442,6 +2442,7 @@ async def _reserve_budget_after_common_checks(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
end_user_id=end_user_id,
|
||||
end_user_object=end_user_object,
|
||||
skip_user_budget_on_team_key=general_settings.get("skip_user_budget_on_team_key") is True,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14805,6 +14805,7 @@ async def get_config_list(
|
|||
"forward_client_headers_to_llm_api": {"type": "Boolean"},
|
||||
"mcp_required_fields": {"type": "List"},
|
||||
"cancel_on_disconnect": {"type": "Boolean"},
|
||||
"skip_user_budget_on_team_key": {"type": "Boolean"},
|
||||
}
|
||||
|
||||
return_val = []
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ async def reserve_budget_for_request(
|
|||
proxy_logging_obj: ProxyLogging,
|
||||
end_user_id: Optional[str] = None,
|
||||
end_user_object: Optional[Any] = None,
|
||||
skip_user_budget_on_team_key: bool = False,
|
||||
) -> Optional[dict]:
|
||||
if valid_token is None or not RouteChecks.is_llm_api_route(route=route):
|
||||
return None
|
||||
|
|
@ -141,6 +142,7 @@ async def reserve_budget_for_request(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
end_user_id=end_user_id,
|
||||
end_user_object=end_user_object,
|
||||
skip_user_budget_on_team_key=skip_user_budget_on_team_key,
|
||||
)
|
||||
if not counters:
|
||||
return None
|
||||
|
|
@ -296,6 +298,7 @@ async def _get_budget_counters(
|
|||
proxy_logging_obj: ProxyLogging,
|
||||
end_user_id: Optional[str] = None,
|
||||
end_user_object: Optional[Any] = None,
|
||||
skip_user_budget_on_team_key: bool = False,
|
||||
) -> List[_BudgetCounter]:
|
||||
counters: List[_BudgetCounter] = []
|
||||
|
||||
|
|
@ -344,8 +347,9 @@ async def _get_budget_counters(
|
|||
)
|
||||
)
|
||||
|
||||
is_team_key = team_object is not None and team_object.team_id is not None
|
||||
if (
|
||||
(team_object is None or team_object.team_id is None)
|
||||
not (is_team_key and skip_user_budget_on_team_key)
|
||||
and user_object is not None
|
||||
and user_object.user_id is not None
|
||||
and user_object.max_budget is not None
|
||||
|
|
|
|||
|
|
@ -219,8 +219,8 @@ async def test_aaauser_personal_budgets(key_ownership):
|
|||
"""
|
||||
Set a personal budget on a user
|
||||
|
||||
- have it only apply when key belongs to user -> raises BudgetExceededError
|
||||
- if key belongs to team, have key respect team budget -> allows call to go through
|
||||
User budget is enforced regardless of key ownership (personal or team).
|
||||
Both cases should raise BudgetExceededError when the user is over budget.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
|
|
@ -229,7 +229,12 @@ async def test_aaauser_personal_budgets(key_ownership):
|
|||
from starlette.datastructures import URL
|
||||
import litellm
|
||||
|
||||
from litellm.proxy._types import LiteLLM_UserTable, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.proxy_server import hash_token, user_api_key_cache
|
||||
|
||||
|
|
@ -273,14 +278,9 @@ async def test_aaauser_personal_budgets(key_ownership):
|
|||
== valid_token
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_api_key_auth(request=request, api_key="Bearer " + user_key)
|
||||
|
||||
if key_ownership == "user_key":
|
||||
pytest.fail("Expected this call to fail. User is over limit.")
|
||||
except Exception:
|
||||
if key_ownership == "team_key":
|
||||
pytest.fail("Expected this call to work. Key is below team budget.")
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -4445,3 +4445,85 @@ async def test_common_checks_personal_user_budget_blocks_in_gather():
|
|||
request=MagicMock(spec=Request),
|
||||
)
|
||||
assert "User=u1" in str(over.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_budget_enforced_on_team_key():
|
||||
"""User budget must be enforced even when the key belongs to a team.
|
||||
|
||||
Previously _user_max_budget_check skipped enforcement for team keys,
|
||||
letting a user with a $100 personal budget spend unlimited through a
|
||||
team key. This regression test ensures that is no longer the case.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.auth.auth_checks import common_checks
|
||||
|
||||
user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0)
|
||||
team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0)
|
||||
token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1")
|
||||
|
||||
async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs):
|
||||
return 999.0 if counter_key == "spend:user:u1" else 0.0
|
||||
|
||||
async def _no_membership(*a, **kw):
|
||||
return None
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend", _spend_by_counter
|
||||
), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership):
|
||||
with pytest.raises(litellm.BudgetExceededError) as over:
|
||||
await common_checks(
|
||||
request_body={"messages": [{"role": "user", "content": "hi"}]},
|
||||
team_object=team,
|
||||
user_object=user,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={},
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
valid_token=token,
|
||||
request=MagicMock(spec=Request),
|
||||
)
|
||||
assert "User=u1" in str(over.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_user_budget_on_team_key_flag_restores_old_behavior():
|
||||
"""Setting skip_user_budget_on_team_key=True skips user budget for team keys.
|
||||
|
||||
This is the opt-in escape hatch that restores the legacy behavior where
|
||||
user budgets were not enforced when the key belonged to a team.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.auth.auth_checks import common_checks
|
||||
|
||||
user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0)
|
||||
team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0)
|
||||
token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1")
|
||||
|
||||
async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs):
|
||||
return 999.0 if counter_key == "spend:user:u1" else 0.0
|
||||
|
||||
async def _no_membership(*a, **kw):
|
||||
return None
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend", _spend_by_counter
|
||||
), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership):
|
||||
result = await common_checks(
|
||||
request_body={"messages": [{"role": "user", "content": "hi"}]},
|
||||
team_object=team,
|
||||
user_object=user,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={"skip_user_budget_on_team_key": True},
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
valid_token=token,
|
||||
request=MagicMock(spec=Request),
|
||||
)
|
||||
assert result is True
|
||||
|
|
|
|||
|
|
@ -576,6 +576,83 @@ async def test_should_reserve_team_member_and_org_budget_counters(spend_counter_
|
|||
await release_budget_reservation(reservation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_reserve_user_budget_counter_for_team_key(spend_counter_state):
|
||||
"""A user's personal budget must be reserved even when the key belongs to a team.
|
||||
|
||||
Regression for GitHub issue #12905: previously the reservation path skipped the
|
||||
user spend counter whenever the key had a team, so a team key could overshoot the
|
||||
user's personal max_budget under concurrency.
|
||||
"""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="key-user-on-team",
|
||||
spend=0.0,
|
||||
user_id="user-on-team",
|
||||
team_id="team-no-budget",
|
||||
)
|
||||
team_object = LiteLLM_TeamTable(team_id="team-no-budget", spend=0.0, max_budget=None)
|
||||
user_object = LiteLLM_UserTable(user_id="user-on-team", spend=0.0, max_budget=5.0)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
|
||||
return_value=0.3,
|
||||
):
|
||||
reservation = await reserve_budget_for_request(
|
||||
request_body=_request_body(),
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
user_object=user_object,
|
||||
prisma_client=None,
|
||||
user_api_key_cache=key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team") == pytest.approx(0.3)
|
||||
|
||||
await release_budget_reservation(reservation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_skip_user_budget_counter_for_team_key_when_flag_set(spend_counter_state):
|
||||
"""skip_user_budget_on_team_key=True restores the legacy behavior where a user's
|
||||
personal budget is not reserved for a team key."""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="key-user-on-team-skip",
|
||||
spend=0.0,
|
||||
user_id="user-on-team-skip",
|
||||
team_id="team-no-budget-skip",
|
||||
)
|
||||
team_object = LiteLLM_TeamTable(team_id="team-no-budget-skip", spend=0.0, max_budget=None)
|
||||
user_object = LiteLLM_UserTable(user_id="user-on-team-skip", spend=0.0, max_budget=5.0)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
|
||||
return_value=0.3,
|
||||
):
|
||||
reservation = await reserve_budget_for_request(
|
||||
request_body=_request_body(),
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
user_object=user_object,
|
||||
prisma_client=None,
|
||||
user_api_key_cache=key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
skip_user_budget_on_team_key=True,
|
||||
)
|
||||
|
||||
assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team-skip") is None
|
||||
|
||||
await release_budget_reservation(reservation)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_seed_org_counter_from_with_budget_cache(spend_counter_state):
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
|
|
|
|||
|
|
@ -8649,6 +8649,38 @@ def test_get_config_list_includes_cancel_on_disconnect(monkeypatch):
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_config_list_includes_skip_user_budget_on_team_key(monkeypatch):
|
||||
"""Related to #12905: the opt-out flag must be discoverable via /config/list so
|
||||
it renders as a Boolean toggle on the Admin UI General Settings table. This
|
||||
requires both the ConfigGeneralSettings field and the allowed_args entry."""
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_config_table = MagicMock()
|
||||
mock_config_table.find_first = AsyncMock(return_value=None)
|
||||
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/config/list", params={"config_type": "general_settings"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
fields = {item["field_name"]: item for item in resp.json()}
|
||||
assert "skip_user_budget_on_team_key" in fields
|
||||
assert fields["skip_user_budget_on_team_key"]["field_type"] == "Boolean"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch):
|
||||
"""The throttle fraction is a litellm_settings scalar surfaced on the General
|
||||
Settings table as a Float field so it sits with the other global limits; it
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -22619,6 +22619,11 @@ export interface components {
|
|||
* @description When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata.
|
||||
*/
|
||||
reject_clientside_metadata_tags?: boolean | null;
|
||||
/**
|
||||
* Skip User Budget On Team Key
|
||||
* @description If True, restores the legacy behavior where a user's personal max_budget is NOT enforced when their key belongs to a team; only the team (and team-member) budgets apply. Defaults to False, meaning the user's personal max_budget is always enforced regardless of whether the key belongs to a team (see GitHub issue #12905).
|
||||
*/
|
||||
skip_user_budget_on_team_key?: boolean | null;
|
||||
/**
|
||||
* Store Model In Db
|
||||
* @description If True, models and config are stored in and loaded from the database. Default is False.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue