Skip to content

Commit 4039559

Browse files
authored
[Feat] UI - Allow editing team member rpm/tpm limits (#13669)
* show team member tpm/rpm limits * ui - allow setting team settings * fix better debugging * fix types: TeamMemberUpdateRequest * add _upsert_budget_and_membership * allow updating team member RPM/TPM in teamMemberUpdateCall * editing team member rpm/tpm * UI - fixes for team member component * fix info * test_upsert_rpm_only_creates_new_budget
1 parent 9f17bed commit 4039559

File tree

8 files changed

+355
-85
lines changed

8 files changed

+355
-85
lines changed

litellm/proxy/_types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,11 +2762,22 @@ class TeamMemberDeleteRequest(MemberDeleteRequest):
27622762
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
27632763
max_budget_in_team: Optional[float] = None
27642764
role: Optional[Literal["admin", "user"]] = None
2765+
tpm_limit: Optional[int] = Field(
2766+
default=None,
2767+
description="Tokens per minute limit for this team member"
2768+
)
2769+
rpm_limit: Optional[int] = Field(
2770+
default=None,
2771+
description="Requests per minute limit for this team member"
2772+
)
2773+
27652774

27662775

27672776
class TeamMemberUpdateResponse(MemberUpdateResponse):
27682777
team_id: str
27692778
max_budget_in_team: Optional[float] = None
2779+
tpm_limit: Optional[int] = None
2780+
rpm_limit: Optional[int] = None
27702781

27712782

27722783
class TeamModelAddRequest(BaseModel):

litellm/proxy/management_endpoints/common_utils.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Optional, Union
1+
from typing import Any, Dict, Optional, Union
22

33
from litellm.proxy._types import (
44
KeyRequestBase,
@@ -56,6 +56,8 @@ async def _upsert_budget_and_membership(
5656
max_budget: Optional[float],
5757
existing_budget_id: Optional[str],
5858
user_api_key_dict: UserAPIKeyAuth,
59+
tpm_limit: Optional[int] = None,
60+
rpm_limit: Optional[int] = None,
5961
):
6062
"""
6163
Helper function to Create/Update or Delete the budget within the team membership
@@ -66,33 +68,34 @@ async def _upsert_budget_and_membership(
6668
max_budget: The maximum budget for the team
6769
existing_budget_id: The ID of the existing budget, if any
6870
user_api_key_dict: User API Key dictionary containing user information
71+
tpm_limit: Tokens per minute limit for the team member
72+
rpm_limit: Requests per minute limit for the team member
6973
70-
If max_budget is None, the user's budget is removed from the team membership.
71-
If max_budget exists, a budget is updated or created and linked to the team membership.
74+
If max_budget, tpm_limit, and rpm_limit are all None, the user's budget is removed from the team membership.
75+
If any of these values exist, a budget is updated or created and linked to the team membership.
7276
"""
73-
if max_budget is None:
74-
# disconnect the budget since max_budget is None
77+
if max_budget is None and tpm_limit is None and rpm_limit is None:
78+
# disconnect the budget since all limits are None
7579
await tx.litellm_teammembership.update(
7680
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
7781
data={"litellm_budget_table": {"disconnect": True}},
7882
)
7983
return
8084

81-
if existing_budget_id:
82-
# update the existing budget
83-
await tx.litellm_budgettable.update(
84-
where={"budget_id": existing_budget_id},
85-
data={"max_budget": max_budget},
86-
)
87-
return
88-
8985
# create a new budget
86+
create_data: Dict[str, Any] = {
87+
"created_by": user_api_key_dict.user_id or "",
88+
"updated_by": user_api_key_dict.user_id or "",
89+
}
90+
if max_budget is not None:
91+
create_data["max_budget"] = max_budget
92+
if tpm_limit is not None:
93+
create_data["tpm_limit"] = tpm_limit
94+
if rpm_limit is not None:
95+
create_data["rpm_limit"] = rpm_limit
96+
9097
new_budget = await tx.litellm_budgettable.create(
91-
data={
92-
"max_budget": max_budget,
93-
"created_by": user_api_key_dict.user_id or "",
94-
"updated_by": user_api_key_dict.user_id or "",
95-
},
98+
data=create_data,
9699
include={"team_membership": True},
97100
)
98101
# upsert the team membership with the new/updated budget

litellm/proxy/management_endpoints/team_endpoints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,8 @@ async def team_member_update(
16171617
max_budget=data.max_budget_in_team,
16181618
existing_budget_id=identified_budget_id,
16191619
user_api_key_dict=user_api_key_dict,
1620+
tpm_limit=data.tpm_limit,
1621+
rpm_limit=data.rpm_limit,
16201622
)
16211623

16221624
### update team member role
@@ -1647,6 +1649,8 @@ async def team_member_update(
16471649
user_id=received_user_id,
16481650
user_email=data.user_email,
16491651
max_budget_in_team=data.max_budget_in_team,
1652+
tpm_limit=data.tpm_limit,
1653+
rpm_limit=data.rpm_limit,
16501654
)
16511655

16521656

tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py

Lines changed: 169 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# tests/litellm/proxy/common_utils/test_upsert_budget_membership.py
22
import types
3-
import pytest
43
from unittest.mock import AsyncMock, MagicMock
54

5+
import pytest
6+
67
from litellm.proxy.management_endpoints.common_utils import (
78
_upsert_budget_and_membership,
89
)
910

10-
1111
# ---------------------------------------------------------------------------
1212
# Fixtures: a fake Prisma transaction and a fake UserAPIKeyAuth object
1313
# ---------------------------------------------------------------------------
@@ -63,25 +63,51 @@ async def test_upsert_disconnect(mock_tx, fake_user):
6363
mock_tx.litellm_teammembership.upsert.assert_not_called()
6464

6565

66-
# TEST: existing budget id, update only
66+
# TEST: existing budget id, creates new budget (current behavior)
6767
@pytest.mark.asyncio
68-
async def test_upsert_update_existing(mock_tx, fake_user):
68+
async def test_upsert_with_existing_budget_id_creates_new(mock_tx, fake_user):
69+
"""
70+
Test that even when existing_budget_id is provided, the function creates a new budget.
71+
This reflects the current implementation behavior.
72+
"""
6973
await _upsert_budget_and_membership(
7074
mock_tx,
7175
team_id="team-2",
7276
user_id="user-2",
7377
max_budget=42.0,
74-
existing_budget_id="bud-999",
78+
existing_budget_id="bud-999", # This parameter is currently unused
7579
user_api_key_dict=fake_user,
7680
)
7781

78-
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
79-
where={"budget_id": "bud-999"},
80-
data={"max_budget": 42.0},
82+
# Should create a new budget, not update existing
83+
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
84+
data={
85+
"max_budget": 42.0,
86+
"created_by": fake_user.user_id,
87+
"updated_by": fake_user.user_id,
88+
},
89+
include={"team_membership": True},
90+
)
91+
92+
# Should upsert team membership with the new budget ID
93+
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
94+
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
95+
where={"user_id_team_id": {"user_id": "user-2", "team_id": "team-2"}},
96+
data={
97+
"create": {
98+
"user_id": "user-2",
99+
"team_id": "team-2",
100+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
101+
},
102+
"update": {
103+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
104+
},
105+
},
81106
)
107+
108+
# Should NOT update existing budget
109+
mock_tx.litellm_budgettable.update.assert_not_called()
82110
mock_tx.litellm_teammembership.update.assert_not_called()
83-
mock_tx.litellm_budgettable.create.assert_not_called()
84-
mock_tx.litellm_teammembership.upsert.assert_not_called()
85111

86112

87113
# TEST: create new budget and link membership
@@ -126,9 +152,13 @@ async def test_upsert_create_and_link(mock_tx, fake_user):
126152
mock_tx.litellm_budgettable.update.assert_not_called()
127153

128154

129-
# TEST: create new budget and link membership, then update
155+
# TEST: create new budget and link membership, then create another new budget
130156
@pytest.mark.asyncio
131-
async def test_upsert_create_then_update(mock_tx, fake_user):
157+
async def test_upsert_create_then_create_another(mock_tx, fake_user):
158+
"""
159+
Test that multiple calls to _upsert_budget_and_membership create separate budgets,
160+
reflecting the current implementation behavior.
161+
"""
132162
# FIRST CALL – create new budget and link membership
133163
await _upsert_budget_and_membership(
134164
mock_tx,
@@ -146,25 +176,143 @@ async def test_upsert_create_then_update(mock_tx, fake_user):
146176
mock_tx.litellm_budgettable.create.assert_awaited_once()
147177
mock_tx.litellm_teammembership.upsert.assert_awaited_once()
148178

149-
# SECOND CALL – pretend the same membership already exists, and
150-
# reset call history so the next assertions are clear
179+
# SECOND CALL – reset call history and create another budget
151180
mock_tx.litellm_budgettable.create.reset_mock()
152181
mock_tx.litellm_teammembership.upsert.reset_mock()
153182
mock_tx.litellm_budgettable.update.reset_mock()
154183

184+
# Set up a new budget ID for the second create call
185+
mock_tx.litellm_budgettable.create.return_value = types.SimpleNamespace(budget_id="new-budget-456")
186+
155187
await _upsert_budget_and_membership(
156188
mock_tx,
157189
team_id="team-42",
158190
user_id="user-42",
159191
max_budget=25.0, # new limit
160-
existing_budget_id=created_bid, # now we say it exists
192+
existing_budget_id=created_bid, # this is ignored in current implementation
161193
user_api_key_dict=fake_user,
162194
)
163195

164-
# Now we expect ONLY an update to fire
165-
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
166-
where={"budget_id": created_bid},
167-
data={"max_budget": 25.0},
196+
# Should create another new budget (not update existing)
197+
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
198+
data={
199+
"max_budget": 25.0,
200+
"created_by": fake_user.user_id,
201+
"updated_by": fake_user.user_id,
202+
},
203+
include={"team_membership": True},
204+
)
205+
206+
# Should upsert team membership with the new budget ID
207+
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
208+
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
209+
where={"user_id_team_id": {"user_id": "user-42", "team_id": "team-42"}},
210+
data={
211+
"create": {
212+
"user_id": "user-42",
213+
"team_id": "team-42",
214+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
215+
},
216+
"update": {
217+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
218+
},
219+
},
220+
)
221+
222+
# Should NOT call update
223+
mock_tx.litellm_budgettable.update.assert_not_called()
224+
225+
226+
# TEST: update rpm_limit for member with existing budget_id
227+
@pytest.mark.asyncio
228+
async def test_upsert_rpm_limit_update_creates_new_budget(mock_tx, fake_user):
229+
"""
230+
Test that updating rpm_limit for a member with an existing budget_id
231+
creates a new budget with the new rpm/tpm limits and assigns it to the user.
232+
"""
233+
existing_budget_id = "existing-budget-456"
234+
235+
await _upsert_budget_and_membership(
236+
mock_tx,
237+
team_id="team-rpm-test",
238+
user_id="user-rpm-test",
239+
max_budget=50.0,
240+
existing_budget_id=existing_budget_id,
241+
user_api_key_dict=fake_user,
242+
tpm_limit=1000,
243+
rpm_limit=100, # updating rpm_limit
244+
)
245+
246+
# Should create a new budget with all the specified limits
247+
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
248+
data={
249+
"max_budget": 50.0,
250+
"tpm_limit": 1000,
251+
"rpm_limit": 100,
252+
"created_by": fake_user.user_id,
253+
"updated_by": fake_user.user_id,
254+
},
255+
include={"team_membership": True},
256+
)
257+
258+
# Should NOT update the existing budget
259+
mock_tx.litellm_budgettable.update.assert_not_called()
260+
261+
# Should upsert team membership with the new budget ID
262+
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
263+
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
264+
where={"user_id_team_id": {"user_id": "user-rpm-test", "team_id": "team-rpm-test"}},
265+
data={
266+
"create": {
267+
"user_id": "user-rpm-test",
268+
"team_id": "team-rpm-test",
269+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
270+
},
271+
"update": {
272+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
273+
},
274+
},
275+
)
276+
277+
278+
# TEST: create new budget with only rpm_limit (no max_budget)
279+
@pytest.mark.asyncio
280+
async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user):
281+
"""
282+
Test that setting only rpm_limit creates a new budget with just the rpm_limit.
283+
"""
284+
await _upsert_budget_and_membership(
285+
mock_tx,
286+
team_id="team-rpm-only",
287+
user_id="user-rpm-only",
288+
max_budget=None,
289+
existing_budget_id=None,
290+
user_api_key_dict=fake_user,
291+
rpm_limit=50,
292+
)
293+
294+
# Should create a new budget with only rpm_limit
295+
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
296+
data={
297+
"rpm_limit": 50,
298+
"created_by": fake_user.user_id,
299+
"updated_by": fake_user.user_id,
300+
},
301+
include={"team_membership": True},
302+
)
303+
304+
# Should upsert team membership with the new budget ID
305+
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
306+
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
307+
where={"user_id_team_id": {"user_id": "user-rpm-only", "team_id": "team-rpm-only"}},
308+
data={
309+
"create": {
310+
"user_id": "user-rpm-only",
311+
"team_id": "team-rpm-only",
312+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
313+
},
314+
"update": {
315+
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
316+
},
317+
},
168318
)
169-
mock_tx.litellm_budgettable.create.assert_not_called()
170-
mock_tx.litellm_teammembership.upsert.assert_not_called()

0 commit comments

Comments
 (0)