-
Notifications
You must be signed in to change notification settings - Fork 1
Merge teams #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Merge teams #117
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
de320f5
Add team merge functionality with conflict resolution strategies
PhiRho dd14da0
Enhance team merge functionality to check for active product associat…
PhiRho b799491
Add team merging feature with user confirmation and conflict resolution
PhiRho b661912
Refactor team expiration logic to simplify checks for active products
PhiRho 22555e7
Enhance access token creation to support dynamic cookie expiration ba…
PhiRho c73950d
Refactor team ID formatting and streamline user/key migration during …
PhiRho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,22 @@ | ||
from fastapi import APIRouter, Depends, HTTPException, status | ||
from sqlalchemy.orm import Session | ||
from typing import List | ||
from typing import List, Optional | ||
from datetime import datetime, UTC | ||
import logging | ||
|
||
from app.db.database import get_db | ||
from app.db.models import DBTeam, DBTeamProduct, DBUser | ||
from app.db.models import DBTeam, DBTeamProduct, DBUser, DBPrivateAIKey, DBRegion | ||
from app.core.security import check_system_admin, check_specific_team_admin, get_current_user_from_auth | ||
from app.schemas.models import ( | ||
Team, TeamCreate, TeamUpdate, | ||
TeamWithUsers | ||
TeamWithUsers, TeamMergeRequest, TeamMergeResponse | ||
) | ||
from app.core.resource_limits import DEFAULT_KEY_DURATION, DEFAULT_MAX_SPEND, DEFAULT_RPM_PER_KEY | ||
from app.services.litellm import LiteLLMService | ||
from app.services.ses import SESService | ||
from app.core.worker import get_team_keys_by_region, generate_pricing_url, get_team_admin_email | ||
from app.api.private_ai_keys import delete_private_ai_key | ||
|
||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
@@ -201,7 +203,6 @@ async def extend_team_trial( | |
except Exception as e: | ||
logger.error(f"Failed to update key {key.id} via LiteLLM: {str(e)}") | ||
# Continue with other keys even if one fails | ||
continue | ||
|
||
# Send trial extension email | ||
try: | ||
|
@@ -219,3 +220,200 @@ async def extend_team_trial( | |
# Don't fail the request if email fails | ||
|
||
return {"message": "Team trial extended successfully"} | ||
|
||
def _check_key_name_conflicts(team1_keys: List[DBPrivateAIKey], team2_keys: List[DBPrivateAIKey]) -> List[str]: | ||
"""Return list of conflicting key names between two teams""" | ||
team1_names = {key.name for key in team1_keys if key.name} | ||
team2_names = {key.name for key in team2_keys if key.name} | ||
return list(team1_names.intersection(team2_names)) | ||
|
||
async def _resolve_key_conflicts( | ||
conflicts: List[str], | ||
strategy: str, | ||
team2_keys: List[DBPrivateAIKey], | ||
rename_suffix: str, | ||
db: Session = None, | ||
current_user = None | ||
) -> List[DBPrivateAIKey]: | ||
"""Apply conflict resolution strategy to team2 keys""" | ||
if strategy == "delete": | ||
# Remove conflicting keys from team2 and delete them from database | ||
keys_to_delete = [key for key in team2_keys if key.name in conflicts] | ||
remaining_keys = [key for key in team2_keys if key.name not in conflicts] | ||
|
||
# Delete conflicting keys from database if db session provided | ||
if db and current_user: | ||
for key in keys_to_delete: | ||
try: | ||
await delete_private_ai_key( | ||
key_id=key.id, | ||
current_user=current_user, | ||
user_role="system_admin", # System admin context for merge operations | ||
db=db | ||
) | ||
except Exception as e: | ||
logger.error(f"Failed to delete key {key.id}: {str(e)}") | ||
# Continue with other keys even if one fails | ||
|
||
return remaining_keys | ||
elif strategy == "rename": | ||
# Rename conflicting keys in team2 | ||
suffix = rename_suffix | ||
for key in team2_keys: | ||
if key.name in conflicts: | ||
key.name = f"{key.name}{suffix}" | ||
return team2_keys | ||
elif strategy == "cancel": | ||
# Return original keys unchanged | ||
return team2_keys | ||
else: | ||
raise ValueError(f"Unknown conflict resolution strategy: {strategy}") | ||
|
||
@router.post("/{target_team_id}/merge", dependencies=[Depends(check_system_admin)]) | ||
async def merge_teams( | ||
target_team_id: int, | ||
merge_request: TeamMergeRequest, | ||
db: Session = Depends(get_db), | ||
current_user: DBUser = Depends(get_current_user_from_auth) | ||
): | ||
""" | ||
Merge source team into target team. Only accessible by system administrators. | ||
|
||
This endpoint will: | ||
1. Validate both teams exist | ||
2. Check if source team has active product associations (fails if it does) | ||
3. Check for key name conflicts | ||
4. Apply conflict resolution strategy | ||
5. Migrate users and keys | ||
6. Update LiteLLM key associations | ||
7. Delete the source team | ||
""" | ||
try: | ||
# Validate teams exist | ||
target_team = db.query(DBTeam).filter(DBTeam.id == target_team_id).first() | ||
if not target_team: | ||
raise HTTPException(status_code=404, detail="Target team not found") | ||
|
||
source_team = db.query(DBTeam).filter(DBTeam.id == merge_request.source_team_id).first() | ||
if not source_team: | ||
raise HTTPException(status_code=404, detail="Source team not found") | ||
|
||
# Prevent merging a team into itself | ||
if source_team.id == target_team.id: | ||
raise HTTPException( | ||
status_code=400, | ||
detail="Cannot merge a team into itself" | ||
) | ||
|
||
# Check if source team has active product associations first | ||
source_products = db.query(DBTeamProduct).filter(DBTeamProduct.team_id == source_team.id).all() | ||
if source_products: | ||
product_names = [product.product_id for product in source_products] | ||
raise HTTPException( | ||
status_code=400, | ||
detail=f"Cannot merge team '{source_team.name}' - it has active product associations: {', '.join(product_names)}. Please remove product associations before merging." | ||
) | ||
|
||
# Get team keys and users (only if no product associations found) | ||
source_keys = db.query(DBPrivateAIKey).filter(DBPrivateAIKey.team_id == source_team.id).all() | ||
target_keys = db.query(DBPrivateAIKey).filter(DBPrivateAIKey.team_id == target_team.id).all() | ||
source_users = db.query(DBUser).filter(DBUser.team_id == source_team.id).all() | ||
|
||
# Check for conflicts | ||
conflicts = _check_key_name_conflicts(target_keys, source_keys) | ||
|
||
# Apply conflict resolution strategy | ||
if conflicts: | ||
if merge_request.conflict_resolution_strategy == "cancel": | ||
return TeamMergeResponse( | ||
success=False, | ||
message=f"Merge cancelled due to {len(conflicts)} key name conflicts", | ||
conflicts_resolved=conflicts, | ||
keys_migrated=0, | ||
users_migrated=0 | ||
) | ||
|
||
source_keys = await _resolve_key_conflicts( | ||
conflicts, | ||
merge_request.conflict_resolution_strategy, | ||
source_keys, | ||
merge_request.rename_suffix if merge_request.rename_suffix is not None else f"_team{source_team.id}", | ||
db, | ||
current_user | ||
) | ||
|
||
# Store team names before deletion | ||
source_team_name = source_team.name | ||
target_team_name = target_team.name | ||
|
||
# Migrate users from source team to target team | ||
users_migrated = 0 | ||
for user in source_users: | ||
if user.team_id != target_team.id: | ||
user.team_id = target_team.id | ||
users_migrated += 1 | ||
|
||
# Migrate keys from source team to target team | ||
keys_migrated = 0 | ||
for key in source_keys: | ||
if key.team_id != target_team.id: | ||
key.team_id = target_team.id | ||
keys_migrated += 1 | ||
|
||
# Flush changes to ensure they're persisted in the current transaction | ||
db.flush() | ||
|
||
# Update LiteLLM key associations | ||
# Create a map of keys by region to avoid unnecessary DB queries | ||
keys_by_region = {} | ||
for key in source_keys: | ||
if key.region_id not in keys_by_region: | ||
keys_by_region[key.region_id] = [] | ||
keys_by_region[key.region_id].append(key) | ||
|
||
# Update LiteLLM key associations for each region | ||
for region_id, region_keys in keys_by_region.items(): | ||
# Get region info | ||
region = db.query(DBRegion).filter( | ||
DBRegion.id == region_id, | ||
DBRegion.is_active == True | ||
).first() | ||
|
||
# Initialize LiteLLM service for this region | ||
litellm_service = LiteLLMService( | ||
api_url=region.litellm_api_url, | ||
api_key=region.litellm_api_key | ||
) | ||
|
||
# Update team association for each key in this region | ||
for key in region_keys: | ||
try: | ||
await litellm_service.update_key_team_association( | ||
key.litellm_token, | ||
LiteLLMService.format_team_id(region.name, target_team.id) | ||
) | ||
except Exception as e: | ||
logger.error(f"Failed to update LiteLLM key {key.id}: {str(e)}") | ||
|
||
# Delete source team | ||
db.delete(source_team) | ||
db.commit() | ||
|
||
return TeamMergeResponse( | ||
success=True, | ||
message=f"Successfully merged team '{source_team_name}' into '{target_team_name}'", | ||
conflicts_resolved=conflicts if conflicts else None, | ||
keys_migrated=keys_migrated, | ||
users_migrated=users_migrated | ||
) | ||
|
||
except HTTPException: | ||
# Re-raise HTTP exceptions as-is | ||
raise | ||
except Exception as e: | ||
db.rollback() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will this actually work if commits have been made in the |
||
logger.error(f"Error during team merge: {str(e)}") | ||
raise HTTPException( | ||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
detail=f"Team merge failed: {str(e)}" | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there any scenario where these get deleted but then for whatever reason the save/commit call fails when we actually do the migration and then the keys are gone?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is, but I don't have a good way to rollback in that case since this delete is fully destructive including tearing down vector DBs if they are part of the key. Given that the keys being deleted is the expected behaviour I'm OK with the risk.