diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 4e769e153fd..f9869b165ed 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4548,10 +4548,12 @@ async def delete_team( ) await _invalidate_deleted_team_member_cache( teams=team_rows, + prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, ) for deleted_team in team_rows: + _emit_team_members_metric(deleted_team.model_copy(update={"members_with_roles": []})) await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id) return deleted_teams @@ -4628,16 +4630,23 @@ async def _invalidate_deleted_team_cache( async def _invalidate_deleted_team_member_cache( teams: Sequence[LiteLLM_TeamTable], + prisma_client: PrismaClient, user_api_key_cache: UserApiKeyCache, ) -> None: for team in teams: - await _evict_deleted_team_member_cache(team=team, user_api_key_cache=user_api_key_cache) + await _evict_deleted_team_member_cache( + team=team, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) -async def _evict_deleted_team_member_cache(team: LiteLLM_TeamTable, user_api_key_cache: UserApiKeyCache) -> None: - member_user_ids: Final = tuple( - sorted({member.user_id for member in team.members_with_roles if member.user_id is not None}) - ) +async def _evict_deleted_team_member_cache( + team: LiteLLM_TeamTable, + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, +) -> None: + member_user_ids: Final = await _deleted_team_member_user_ids(team=team, prisma_client=prisma_client) await evict_and_broadcast(cache_keys=member_user_ids, user_api_key_cache=user_api_key_cache) await asyncio.gather( *( @@ -4651,6 +4660,25 @@ async def _evict_deleted_team_member_cache(team: LiteLLM_TeamTable, user_api_key ) +async def _deleted_team_member_user_ids(team: LiteLLM_TeamTable, prisma_client: PrismaClient) -> tuple[str, ...]: + roster_user_ids: Final = frozenset( + member.user_id for member in team.members_with_roles if member.user_id is not None + ) + email_only_member_emails: Final = sorted( + { + member.user_email + for member in team.members_with_roles + if member.user_id is None and member.user_email is not None + } + ) + if not email_only_member_emails: + return tuple(sorted(roster_user_ids)) + email_only_users: Final = await _user_db(prisma_client).find_many( + where={"user_email": {"in": email_only_member_emails, "mode": "insensitive"}} + ) + return tuple(sorted(roster_user_ids.union(user.user_id for user in email_only_users))) + + def _transform_teams_to_deleted_records( teams: list[LiteLLM_TeamTable], user_api_key_dict: UserAPIKeyAuth, diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index f8027557f61..3ec06ab5b52 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -9032,7 +9032,8 @@ async def test_delete_team_evicts_member_caches_with_one_transaction( team = LiteLLM_TeamTable( team_id="team-doomed", team_alias="doomed-team", - members_with_roles=[Member(user_id=user_id, role="user") for user_id in member_user_ids], + members_with_roles=[Member(user_id=user_id, role="user") for user_id in member_user_ids] + + [Member(user_id=None, user_email="invitee@example.com", role="user")], metadata={}, model_max_budget={}, model_spend={}, @@ -9046,6 +9047,9 @@ async def test_delete_team_evicts_member_caches_with_one_transaction( mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) mock_prisma_client.db.execute_raw = AsyncMock() mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[SimpleNamespace(user_id="invited-user", user_email="invitee@example.com")] + ) mock_tx = AsyncMock() mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) @@ -9058,6 +9062,7 @@ async def test_delete_team_evicts_member_caches_with_one_transaction( fresh_cache = UserApiKeyCache() for user_id in member_user_ids: fresh_cache.set_cache(key=user_id, value=UserAPIKeyAuth(user_id=user_id)) + fresh_cache.set_cache(key="invited-user", value=UserAPIKeyAuth(user_id="invited-user")) fresh_cache.set_cache(key="bystander-user", value=UserAPIKeyAuth(user_id="bystander-user")) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -9085,6 +9090,12 @@ async def test_delete_team_evicts_member_caches_with_one_transaction( assert fresh_cache.get_cache(key=user_id) is None, ( f"member {user_id}'s cached user object survived the team delete" ) + assert fresh_cache.get_cache(key="invited-user") is None, ( + "the email-only roster entry resolves to invited-user, whose cached user object must be evicted too" + ) + mock_prisma_client.db.litellm_usertable.find_many.assert_awaited_once_with( + where={"user_email": {"in": ["invitee@example.com"], "mode": "insensitive"}} + ) assert fresh_cache.get_cache(key="bystander-user") is not None