fix(proxy): skip members already on the team before resolving a per-member budget

A mixed /team/member_add list that names an existing member used to run
add_new_member for them, which created or cloned a budget that the empty
upsert update branch never linked to their membership row. Filter the
requested members against the freshly locked roster first so budgets and
membership rows are only written for members who are actually new

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-17 01:12:54 +00:00 committed by ryan-crabbe-berri
parent b6f4ad190e
commit 61d4c5b9b5
2 changed files with 79 additions and 26 deletions

View file

@ -2904,10 +2904,15 @@ async def _process_team_members(
if member_allowed_models is None and team_default_member_models:
member_allowed_models = team_default_member_models
if isinstance(data.member, Member):
requested_members: Final[Sequence[Member]] = (
(data.member,) if isinstance(data.member, Member) else tuple(data.member)
)
for m in requested_members:
if _member_already_in_team(m, complete_team_data):
continue
try:
updated_user, updated_tm = await add_new_member(
new_member=data.member,
new_member=m,
max_budget_in_team=data.max_budget_in_team,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
@ -2921,34 +2926,11 @@ async def _process_team_members(
except Exception as e:
raise HTTPException(
status_code=500,
detail={"error": f"Unable to add user - {data.member}, to team - {data.team_id}, for reason - {e}"},
detail={"error": f"Unable to add user - {m}, to team - {data.team_id}, for reason - {e}"},
)
updated_users.append(updated_user)
if updated_tm is not None:
updated_team_memberships.append(updated_tm)
elif isinstance(data.member, list):
for m in data.member:
try:
updated_user, updated_tm = await add_new_member(
new_member=m,
max_budget_in_team=data.max_budget_in_team,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
team_id=data.team_id,
default_team_budget_id=default_team_budget_id,
allowed_models=member_allowed_models,
budget_duration=data.budget_duration,
tx=tx,
)
except Exception as e:
raise HTTPException(
status_code=500,
detail={"error": f"Unable to add user - {m}, to team - {data.team_id}, for reason - {e}"},
)
updated_users.append(updated_user)
if updated_tm is not None:
updated_team_memberships.append(updated_tm)
return updated_users, updated_team_memberships

View file

@ -1794,6 +1794,7 @@ async def test_process_team_members_single_member():
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.metadata = {"team_member_budget_id": "budget-123"}
mock_team.default_team_member_models = None
mock_team.members_with_roles = []
# Mock user and membership objects
mock_user = MagicMock(spec=LiteLLM_UserTable)
@ -1854,6 +1855,7 @@ async def test_process_team_members_multiple_members():
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.metadata = None
mock_team.default_team_member_models = None
mock_team.members_with_roles = []
# Create multiple members as dictionaries (they will be converted to Member objects)
members = [
@ -2114,6 +2116,75 @@ async def test_add_team_members_runs_member_writes_on_the_lock_holding_transacti
assert [tm.budget_id for tm in updated_team_memberships] == ["budget-pool"]
@pytest.mark.asyncio
async def test_add_team_members_skips_budget_and_membership_writes_for_members_already_on_the_roster():
"""
Regression pin for orphaned budgets on a mixed /team/member_add list.
A list naming one member already on the team and one new member must only create a
budget and membership row for the new member. Running add_new_member for the existing
member would create a per-member budget that nothing links to, since their membership
row (and the budget it already carries) is left untouched.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
_add_team_members_to_team,
)
added_user = MagicMock()
added_user.user_id = "bob"
added_user.model_dump.return_value = {"user_id": "bob", "teams": ["team-mixed"]}
created_budget = MagicMock()
created_budget.budget_id = "budget-bob"
membership = MagicMock()
membership.model_dump.return_value = {
"team_id": "team-mixed",
"user_id": "bob",
"budget_id": "budget-bob",
"litellm_budget_table": None,
}
tx = MagicMock()
tx.query_raw = AsyncMock(
return_value=[{"members_with_roles": [{"user_id": "alice", "user_email": None, "role": "user"}]}]
)
tx.litellm_teamtable.update = AsyncMock(
return_value=LiteLLM_TeamTable(team_id="team-mixed", members_with_roles=[])
)
tx.litellm_usertable.upsert = AsyncMock(return_value=added_user)
tx.litellm_usertable.update_many = AsyncMock()
tx.litellm_budgettable.create = AsyncMock(return_value=created_budget)
tx.litellm_teammembership.upsert = AsyncMock(return_value=membership)
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx)
tx_cm.__aexit__ = AsyncMock(return_value=None)
prisma_client = MagicMock()
prisma_client.tx = MagicMock(return_value=tx_cm)
_, updated_users, updated_team_memberships = await _add_team_members_to_team(
data=TeamMemberAddRequest(
team_id="team-mixed",
member=[Member(user_id="alice", role="user"), Member(user_id="bob", role="user")],
max_budget_in_team=50.0,
),
complete_team_data=LiteLLM_TeamTable(team_id="team-mixed", members_with_roles=[]),
prisma_client=cast(object, prisma_client),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
litellm_proxy_admin_name="admin",
)
tx.litellm_budgettable.create.assert_awaited_once()
tx.litellm_teammembership.upsert.assert_awaited_once()
assert tx.litellm_teammembership.upsert.call_args.kwargs["where"] == {
"user_id_team_id": {"user_id": "bob", "team_id": "team-mixed"}
}
assert [user.user_id for user in updated_users] == ["bob"]
assert [tm.user_id for tm in updated_team_memberships] == ["bob"]
written_ids = [m["user_id"] for m in json.loads(tx.litellm_teamtable.update.call_args.kwargs["data"]["members_with_roles"])]
assert written_ids == ["alice", "bob"]
@pytest.mark.asyncio
async def test_add_team_members_writes_nothing_when_the_team_is_deleted_mid_request():
"""