fix(team): reserve team budget raises for proxy admins on /team/update (#30030)

The caller's PERSONAL max_budget was the wrong yardstick for /team/update: a
team's spend ceiling has nothing to do with the admin's own key budget. That
comparison was an unintended side effect of reusing _check_user_team_limits()
(which exists for the /team/new path) and broke the UI, which re-sends the
unchanged budget on every save.

New behavior on /team/update for standalone teams:
- A team admin (already authorized via _verify_team_access) may freely KEEP or
  LOWER the team budget, and change models/tpm/rpm, without being gated by their
  personal limits.
- GROWING a team's spend ceiling is a budget-authority action reserved for proxy
  admins -> 403 for team admins. "Growing" covers both raising max_budget above
  the team's current finite value and removing the cap entirely (max_budget=null,
  detected via model_fields_set so an explicit null is distinguished from an
  omitted field). For a team that currently has no cap, setting a finite value is
  a restriction and is allowed.
- Org-scoped teams remain governed by _check_org_team_limits() (capped by the
  org budget).

Also reverts the #29525 existing_team_max_budget workaround in
_check_user_team_limits() back to the create-only form; /team/new still enforces
the creator's personal caps.

docs(access_control): resolve the contradiction in the team-admin section —
team admins can keep/lower the budget and manage rate limits/models, but cannot
raise the team budget (proxy-admin only).

tests: unit + behavior coverage for raise-blocked, cap-removal-blocked (team
admin), raise/removal allowed (proxy admin), uncapped-team restriction allowed,
keep/lower/resend allowed, and unchanged create-path guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
milan-berri 2026-06-09 19:19:15 +03:00 committed by GitHub
parent 51ba6e39cd
commit d84499e0f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 543 additions and 152 deletions

View File

@ -779,55 +779,40 @@ async def _check_user_team_limits(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
user_api_key_cache: Any,
existing_team_max_budget: Optional[float] = None,
) -> None:
"""
Check user team limits for standalone teams (not org-scoped).
Enforce the caller's personal limits when CREATING a standalone team.
This validates:
- Team budget vs user's max_budget
- Team models vs user's allowed models
This validates the requested team budget / models / tpm / rpm against the
caller's own limits, so a non-admin user cannot mint a brand-new team that
is richer than themselves.
Should only be called for standalone teams (when organization_id is None).
For org-scoped teams, use _check_org_team_limits() instead.
`existing_team_max_budget` is the team's current `max_budget` on the
/team/update path. When the incoming `max_budget` is unchanged or lower
than the team's current budget, the personal-budget comparison is skipped
so a team admin can edit other fields (e.g. tpm_limit, team name) without
being blocked by a budget the team already has. The UI sends the full team
object on every update, so the unchanged `max_budget` would otherwise fail.
Only used by /team/new for standalone teams (organization_id is None).
/team/update does NOT call this an existing team's admin is already
authorized via _verify_team_access() and is not gated by their personal
wallet. Org-scoped teams use _check_org_team_limits() instead.
"""
# Validate team budget against user's max_budget
if data.max_budget is not None and user_api_key_dict.user_id is not None:
# On /team/update, allow unchanged or lower budgets without checking
# the caller's personal max_budget. Only increases above the team's
# current budget are validated against the user's personal limit.
budget_unchanged_or_lower = (
existing_team_max_budget is not None
and data.max_budget <= existing_team_max_budget
user_obj = await get_user_object(
user_id=user_api_key_dict.user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
)
if not budget_unchanged_or_lower:
user_obj = await get_user_object(
user_id=user_api_key_dict.user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
if (
user_obj is not None
and user_obj.max_budget is not None
and data.max_budget > user_obj.max_budget
):
raise HTTPException(
status_code=400,
detail={
"error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}"
},
)
if (
user_obj is not None
and user_obj.max_budget is not None
and data.max_budget > user_obj.max_budget
):
raise HTTPException(
status_code=400,
detail={
"error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}"
},
)
# Validate team models against user's allowed models
if data.models is not None and len(user_api_key_dict.models) > 0:
for m in data.models:
@ -865,6 +850,45 @@ async def _check_user_team_limits(
)
def _check_team_budget_update_authority(
data: UpdateTeamRequest,
user_api_key_dict: UserAPIKeyAuth,
existing_team_max_budget: Optional[float],
) -> None:
"""
Restrict who can grow a standalone team's spend ceiling on /team/update.
A team admin (already authorized via _verify_team_access) may keep or lower
the team budget, but only a proxy admin may grow it - by raising max_budget
above the team's current value or by removing the cap (setting it to None).
Setting a finite budget on a team that has no cap is a restriction and is
allowed. Org-scoped teams are governed by _check_org_team_limits().
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return
if existing_team_max_budget is None:
return
budget_explicitly_set = "max_budget" in (
getattr(data, "model_fields_set", None) or set()
)
if budget_explicitly_set and data.max_budget is None:
raise HTTPException(
status_code=403,
detail={
"error": f"Only a proxy admin can remove a team's max_budget. Team's current max_budget={existing_team_max_budget}."
},
)
if data.max_budget is not None and data.max_budget > existing_team_max_budget:
raise HTTPException(
status_code=403,
detail={
"error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}."
},
)
#### TEAM MANAGEMENT ####
@router.post(
"/team/new",
@ -1827,22 +1851,14 @@ async def update_team( # noqa: PLR0915
prisma_client=prisma_client,
)
# Check user limits for standalone teams (not org-scoped)
# Skip for PROXY_ADMIN users
if (
user_api_key_dict.user_role is None
or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
):
# Only validate user budget/models for standalone teams
# For org-scoped teams, validation is done by _check_org_team_limits() above
if org_id_to_check is None:
await _check_user_team_limits(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
existing_team_max_budget=existing_team_row.max_budget,
)
# Only a proxy admin may grow a standalone team's spend ceiling.
# Org-scoped teams are validated by _check_org_team_limits() above.
if org_id_to_check is None:
_check_team_budget_update_authority(
data=data,
user_api_key_dict=user_api_key_dict,
existing_team_max_budget=existing_team_row.max_budget,
)
updated_kv = data.json(exclude_unset=True)

View File

@ -28,7 +28,7 @@ import pytest
from litellm.proxy.utils import hash_token
from .actors import Actor
from .conftest import create_scratch_org, create_scratch_team
from .conftest import MASTER_KEY, create_scratch_org, create_scratch_team
pytestmark = pytest.mark.asyncio(loop_scope="session")
@ -288,34 +288,130 @@ async def test_check_user_team_limits(
# ---------------------------------------------------------------------------
# /team/update path — _check_user_team_limits on existing team, no-org.
# Pin one over-budget rejection here so the update-side wiring is also
# covered (the update path is a second call site with its own data shape).
# /team/update path — budget authority.
#
# The caller's PERSONAL limits are never applied on update (that compared the
# wrong thing). But raising a team's spend ceiling is reserved for proxy admins:
# a team admin may keep or LOWER the budget, only a proxy admin may RAISE it.
# _check_user_team_limits() only runs on /team/new.
# ---------------------------------------------------------------------------
async def test_team_update_user_limit_rejected(proxy_client, prisma, scratch):
async def test_team_admin_raise_budget_blocked(proxy_client, prisma, scratch):
"""A team admin cannot raise the team's budget; the block is NOT based on
their personal budget (which here is higher than the requested value)."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma,
scratch.prefix,
max_budget=100.0,
max_budget=100000.0, # generous personal budget; must not matter
)
creator_user_id = f"{scratch.prefix}-team-creator"
# Team must exist before /team/update; seed a standalone scratch team
# owned by the same actor so the update authz gate passes.
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[creator_user_id],
max_budget=50.0,
)
# Raise the team budget 50 -> 999 as a team admin.
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller_cleartext}"},
json={"team_id": team_id, "max_budget": 999.0},
)
assert resp.status_code == 400, resp.text
assert resp.status_code == 403, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 50.0, "row max_budget mutated despite rejection"
assert row.max_budget == 50.0, "team budget must not change on a blocked raise"
async def test_team_admin_lower_budget_allowed(proxy_client, prisma, scratch):
"""A team admin may freely lower (or keep) the team's budget."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma,
scratch.prefix,
max_budget=10.0, # below both the old and new team budget; must not matter
)
creator_user_id = f"{scratch.prefix}-team-creator"
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[creator_user_id],
max_budget=500.0,
)
# Lower the team budget 500 -> 300 as a team admin.
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller_cleartext}"},
json={"team_id": team_id, "max_budget": 300.0},
)
assert resp.status_code == 200, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 300.0, "team admin should be able to lower the budget"
async def test_proxy_admin_raise_budget_allowed(proxy_client, prisma, scratch):
"""A proxy admin may raise a team's budget."""
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[f"{scratch.prefix}-team-creator"],
max_budget=50.0,
)
# MASTER_KEY acts as proxy admin.
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {MASTER_KEY}"},
json={"team_id": team_id, "max_budget": 999.0},
)
assert resp.status_code == 200, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 999.0, "proxy admin should be able to raise the budget"
async def test_team_admin_remove_budget_cap_blocked(proxy_client, prisma, scratch):
"""A team admin cannot strip the team's cap (max_budget=null); removing the
ceiling is the strongest possible raise -> proxy-admin only."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma, scratch.prefix, max_budget=100000.0
)
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[f"{scratch.prefix}-team-creator"],
max_budget=50.0,
)
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller_cleartext}"},
json={"team_id": team_id, "max_budget": None},
)
assert resp.status_code == 403, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 50.0, "team budget cap must not be removed by a team admin"
async def test_proxy_admin_remove_budget_cap_allowed(proxy_client, prisma, scratch):
"""A proxy admin may remove a team's cap (max_budget=null)."""
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[f"{scratch.prefix}-team-creator"],
max_budget=50.0,
)
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {MASTER_KEY}"},
json={"team_id": team_id, "max_budget": None},
)
assert resp.status_code == 200, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget is None, "proxy admin should be able to remove the cap"

View File

@ -4451,37 +4451,38 @@ async def test_new_team_org_scoped_models_not_in_org_models():
@pytest.mark.asyncio
async def test_update_team_standalone_budget_exceeds_user_limit():
async def test_update_team_standalone_budget_raise_blocked_for_team_admin():
"""
Test that /team/update for a standalone team fails when new budget exceeds user's max_budget.
Test that /team/update for a standalone team blocks a non-proxy-admin
(team admin) from RAISING the team budget above the team's current value.
Raising a team's spend ceiling is a budget-authority action reserved for
proxy admins. The rejection is NOT based on the caller's personal budget.
Scenario:
- User has personal max_budget=$50
- Standalone team exists (no organization_id)
- User tries to update team budget to $100
- Expected: Should fail with error about exceeding user budget
- Team admin (internal_user) manages the team
- Standalone team exists with current budget=$30
- Admin tries to raise team budget to $100
- Expected: 403 (only a proxy admin may raise the team budget)
"""
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_UserTable,
ProxyException,
UpdateTeamRequest,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import update_team
# Create non-admin user with restrictive personal budget
non_admin_user = UserAPIKeyAuth(
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="non-admin-update-test",
models=[],
)
# Create update request with budget exceeding user's limit
update_request = UpdateTeamRequest(
team_id="standalone-team-123",
max_budget=100.0, # Exceeds user's $50 limit
max_budget=100.0, # Raise above the team's current $30
)
dummy_request = MagicMock(spec=Request)
@ -4492,13 +4493,13 @@ async def test_update_team_standalone_budget_exceeds_user_limit():
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
) as mock_audit,
),
):
# Mock existing standalone team (no organization_id)
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-team-123"
mock_existing_team.organization_id = None # Standalone team
mock_existing_team.max_budget = 30.0
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-team-123",
"organization_id": None,
@ -4510,25 +4511,253 @@ async def test_update_team_standalone_budget_exceeds_user_limit():
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_cache.async_get_cache = AsyncMock(return_value=None)
# Mock user cache to return user with restrictive budget
mock_user_obj = LiteLLM_UserTable(
user_id="non-admin-update-test",
max_budget=50.0, # User's budget limit
)
mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj)
# Should raise ProxyException because new budget exceeds user's max_budget
with pytest.raises(ProxyException) as exc_info:
await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=non_admin_user,
user_api_key_dict=team_admin_user,
)
# Verify exception details
assert exc_info.value.code == "400"
assert "budget" in str(exc_info.value.message).lower()
assert exc_info.value.code == "403"
assert "proxy admin" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin():
"""
Test that a proxy admin CAN raise a standalone team's budget on /team/update.
Scenario:
- Caller is a proxy admin
- Standalone team exists with current budget=$30
- Proxy admin raises team budget to $100
- Expected: Should succeed (proxy admin holds budget authority)
"""
from fastapi import Request
from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import update_team
proxy_admin = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="proxy-admin-update-test",
models=[],
)
update_request = UpdateTeamRequest(
team_id="standalone-team-123",
max_budget=100.0, # Raise above the team's current $30
)
dummy_request = MagicMock(spec=Request)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-team-123"
mock_existing_team.organization_id = None
mock_existing_team.max_budget = 30.0
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-team-123",
"organization_id": None,
"max_budget": 30.0,
"members_with_roles": [
{"user_id": "proxy-admin-update-test", "role": "admin"}
],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
mock_updated_team = MagicMock()
mock_updated_team.team_id = "standalone-team-123"
mock_updated_team.organization_id = None
mock_updated_team.max_budget = 100.0
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "standalone-team-123",
"organization_id": None,
"max_budget": 100.0,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
result = await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=proxy_admin,
)
assert result is not None
assert result["data"].max_budget == 100.0
@pytest.mark.asyncio
async def test_update_team_standalone_budget_removal_blocked_for_team_admin():
"""
A team admin must not be able to REMOVE a team's spend ceiling
(max_budget=null), which is the strongest possible raise (finite -> unlimited).
Scenario:
- Team admin (internal_user) manages a team with current budget=$500
- Admin explicitly sets max_budget=None to strip the cap
- Expected: 403 (only a proxy admin can remove the team budget)
"""
from fastapi import Request
from litellm.proxy._types import (
ProxyException,
UpdateTeamRequest,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import update_team
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="budget-removal-admin",
models=[],
)
# Explicitly set max_budget=None so it lands in model_fields_set and would be
# persisted by data.json(exclude_unset=True).
update_request = UpdateTeamRequest(
team_id="standalone-team-123",
max_budget=None,
)
assert "max_budget" in update_request.model_fields_set
dummy_request = MagicMock(spec=Request)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-team-123"
mock_existing_team.organization_id = None
mock_existing_team.max_budget = 500.0
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-team-123",
"organization_id": None,
"max_budget": 500.0,
"members_with_roles": [
{"user_id": "budget-removal-admin", "role": "admin"}
],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_cache.async_get_cache = AsyncMock(return_value=None)
with pytest.raises(ProxyException) as exc_info:
await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=team_admin_user,
)
assert exc_info.value.code == "403"
assert "remove" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed():
"""
When a team currently has NO cap (max_budget=None / unlimited), a team admin
setting a finite max_budget is a RESTRICTION, not a raise, and is
intentionally allowed.
Scenario:
- Team admin manages a team with current max_budget=None (unlimited)
- Admin sets max_budget=1000 (unlimited -> finite is more restrictive)
- Expected: 200
"""
from fastapi import Request
from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import update_team
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="uncapped-team-admin",
models=[],
)
update_request = UpdateTeamRequest(
team_id="standalone-uncapped-123",
max_budget=1000.0,
)
dummy_request = MagicMock(spec=Request)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-uncapped-123"
mock_existing_team.organization_id = None
mock_existing_team.max_budget = None # team has no cap
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-uncapped-123",
"organization_id": None,
"max_budget": None,
"members_with_roles": [
{"user_id": "uncapped-team-admin", "role": "admin"}
],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
mock_updated_team = MagicMock()
mock_updated_team.team_id = "standalone-uncapped-123"
mock_updated_team.organization_id = None
mock_updated_team.max_budget = 1000.0
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "standalone-uncapped-123",
"organization_id": None,
"max_budget": 1000.0,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
result = await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=team_admin_user,
)
assert result is not None
assert result["data"].max_budget == 1000.0
@pytest.mark.asyncio
@ -4816,32 +5045,34 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
@pytest.mark.asyncio
async def test_update_team_standalone_models_exceeds_user_limit():
async def test_update_team_standalone_models_not_gated_by_user_limit():
"""
Test that /team/update for a standalone team fails when models are not in user's allowed models.
Test that /team/update for a standalone team does NOT gate the team's models
by the caller's personal allowed models.
A team admin authorized via _verify_team_access() may set the team's models
independently of their own personal model list on update.
Scenario:
- User has personal models=['gpt-3.5-turbo']
- Team admin has personal models=['gpt-3.5-turbo']
- Standalone team exists (no organization_id)
- User tries to update team models to ['gpt-4'] (not in user's allowed models)
- Expected: Should fail with error about model not in user's allowed models
- Admin updates team models to ['gpt-4'] (not in their personal list)
- Expected: Should succeed (personal models are irrelevant on /team/update)
"""
from fastapi import Request
from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import update_team
# Create non-admin user with restrictive personal models
non_admin_user = UserAPIKeyAuth(
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="non-admin-update-models-test",
models=["gpt-3.5-turbo"], # Restrictive model list
models=["gpt-3.5-turbo"], # Restrictive personal model list
)
# Create update request with model not in user's allowed list
update_request = UpdateTeamRequest(
team_id="standalone-team-models-123",
models=["gpt-4"], # Not in user's allowed models
models=["gpt-4"], # Not in the admin's personal allowed models
)
dummy_request = MagicMock(spec=Request)
@ -4859,6 +5090,7 @@ async def test_update_team_standalone_models_exceeds_user_limit():
mock_existing_team.team_id = "standalone-team-models-123"
mock_existing_team.organization_id = None # Standalone team
mock_existing_team.models = ["gpt-3.5-turbo"]
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-team-models-123",
"organization_id": None,
@ -4870,18 +5102,30 @@ async def test_update_team_standalone_models_exceeds_user_limit():
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
# Should raise ProxyException because model not in user's allowed models
with pytest.raises(ProxyException) as exc_info:
await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=non_admin_user,
)
mock_updated_team = MagicMock()
mock_updated_team.team_id = "standalone-team-models-123"
mock_updated_team.organization_id = None
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "standalone-team-models-123",
"organization_id": None,
"models": ["gpt-4"],
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
# Verify exception details
assert exc_info.value.code == "400"
assert "model" in str(exc_info.value.message).lower()
result = await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=team_admin_user,
)
assert result is not None
@pytest.mark.asyncio
@ -5306,32 +5550,35 @@ async def test_update_team_org_scoped_models_with_all_proxy_models():
@pytest.mark.asyncio
async def test_update_team_tpm_limit_exceeds_user_limit():
async def test_update_team_tpm_limit_not_gated_by_user_limit():
"""
Test that /team/update fails when TPM limit exceeds user's TPM limit.
Test that /team/update does NOT gate the team's tpm_limit by the caller's
personal tpm_limit.
A team admin authorized via _verify_team_access() may raise the team's
tpm_limit above their own personal tpm_limit on update.
Scenario:
- User has tpm_limit=1000
- User tries to update team with tpm_limit=5000
- Expected: Should fail with error about exceeding user TPM limit
- Team admin has personal tpm_limit=1000
- Standalone team exists with tpm_limit=500
- Admin updates team tpm_limit to 5000 (above their personal 1000)
- Expected: Should succeed (personal tpm is irrelevant on /team/update)
"""
from fastapi import Request
from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import update_team
# Create non-admin user with TPM limit
non_admin_user = UserAPIKeyAuth(
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="tpm-limit-user",
models=[],
tpm_limit=1000, # User's TPM limit
tpm_limit=1000, # Restrictive personal TPM limit
)
# Create update request with TPM exceeding user's limit
update_request = UpdateTeamRequest(
team_id="team-tpm-test-123",
tpm_limit=5000, # Exceeds user's 1000 limit
tpm_limit=5000, # Above the admin's personal 1000
)
dummy_request = MagicMock(spec=Request)
@ -5340,12 +5587,16 @@ async def test_update_team_tpm_limit_exceeds_user_limit():
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
# Mock existing standalone team
mock_existing_team = MagicMock()
mock_existing_team.team_id = "team-tpm-test-123"
mock_existing_team.organization_id = None
mock_existing_team.tpm_limit = 500
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "team-tpm-test-123",
"organization_id": None,
@ -5355,47 +5606,59 @@ async def test_update_team_tpm_limit_exceeds_user_limit():
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
# Should raise ProxyException because new TPM exceeds user's limit
with pytest.raises(ProxyException) as exc_info:
await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=non_admin_user,
)
mock_updated_team = MagicMock()
mock_updated_team.team_id = "team-tpm-test-123"
mock_updated_team.organization_id = None
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "team-tpm-test-123",
"organization_id": None,
"tpm_limit": 5000,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
# Verify exception details
assert exc_info.value.code == "400"
assert "tpm" in str(exc_info.value.message).lower()
result = await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=team_admin_user,
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_rpm_limit_exceeds_user_limit():
async def test_update_team_rpm_limit_not_gated_by_user_limit():
"""
Test that /team/update fails when RPM limit exceeds user's RPM limit.
Test that /team/update does NOT gate the team's rpm_limit by the caller's
personal rpm_limit.
Scenario:
- User has rpm_limit=100
- User tries to update team with rpm_limit=500
- Expected: Should fail with error about exceeding user RPM limit
- Team admin has personal rpm_limit=100
- Standalone team exists with rpm_limit=50
- Admin updates team rpm_limit to 500 (above their personal 100)
- Expected: Should succeed (personal rpm is irrelevant on /team/update)
"""
from fastapi import Request
from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import update_team
# Create non-admin user with RPM limit
non_admin_user = UserAPIKeyAuth(
team_admin_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="rpm-limit-user",
models=[],
rpm_limit=100, # User's RPM limit
rpm_limit=100, # Restrictive personal RPM limit
)
# Create update request with RPM exceeding user's limit
update_request = UpdateTeamRequest(
team_id="team-rpm-test-123",
rpm_limit=500, # Exceeds user's 100 limit
rpm_limit=500, # Above the admin's personal 100
)
dummy_request = MagicMock(spec=Request)
@ -5404,12 +5667,16 @@ async def test_update_team_rpm_limit_exceeds_user_limit():
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
# Mock existing standalone team
mock_existing_team = MagicMock()
mock_existing_team.team_id = "team-rpm-test-123"
mock_existing_team.organization_id = None
mock_existing_team.rpm_limit = 50
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "team-rpm-test-123",
"organization_id": None,
@ -5419,18 +5686,30 @@ async def test_update_team_rpm_limit_exceeds_user_limit():
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
# Should raise ProxyException because new RPM exceeds user's limit
with pytest.raises(ProxyException) as exc_info:
await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=non_admin_user,
)
mock_updated_team = MagicMock()
mock_updated_team.team_id = "team-rpm-test-123"
mock_updated_team.organization_id = None
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "team-rpm-test-123",
"organization_id": None,
"rpm_limit": 500,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
# Verify exception details
assert exc_info.value.code == "400"
assert "rpm" in str(exc_info.value.message).lower()
result = await update_team(
data=update_request,
http_request=dummy_request,
user_api_key_dict=team_admin_user,
)
assert result is not None
@pytest.mark.asyncio