Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,7 @@ class TeamBase(LiteLLMPydanticObjectBase):
budget_duration: Optional[str] = None

models: list = []
default_models: list = []
blocked: bool = False
router_settings: Optional[dict] = None
access_group_ids: Optional[List[str]] = None
Expand Down Expand Up @@ -1580,6 +1581,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
max_budget: Optional[float] = None
soft_budget: Optional[float] = None
models: Optional[list] = None
default_models: Optional[list] = None
blocked: Optional[bool] = None
budget_duration: Optional[str] = None
tags: Optional[list] = None
Expand Down Expand Up @@ -2251,6 +2253,8 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken):
team_max_budget: Optional[float] = None
team_soft_budget: Optional[float] = None
team_models: List = []
team_default_models: List[str] = []
team_member_models: List[str] = []
team_blocked: bool = False
soft_budget: Optional[float] = None
team_model_aliases: Optional[Dict] = None
Expand Down Expand Up @@ -3407,6 +3411,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase):
team_id: str
budget_id: Optional[str] = None
spend: Optional[float] = 0.0
models: List[str] = []
litellm_budget_table: Optional[LiteLLM_BudgetTable]

def safe_get_team_member_rpm_limit(self) -> Optional[int]:
Expand Down Expand Up @@ -3520,6 +3525,7 @@ class TeamMemberAddRequest(MemberAddRequest):
default=None,
description="Maximum budget allocated to this user within the team. If not set, user has unlimited budget within team limits",
)
models: Optional[List[str]] = None


class TeamMemberDeleteRequest(MemberDeleteRequest):
Expand All @@ -3535,13 +3541,15 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
rpm_limit: Optional[int] = Field(
default=None, description="Requests per minute limit for this team member"
)
models: Optional[List[str]] = None


class TeamMemberUpdateResponse(MemberUpdateResponse):
team_id: str
max_budget_in_team: Optional[float] = None
tpm_limit: Optional[int] = None
rpm_limit: Optional[int] = None
models: Optional[List[str]] = None


class TeamModelAddRequest(BaseModel):
Expand Down
32 changes: 31 additions & 1 deletion litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@

from .auth_checks_organization import organization_role_based_access_check
from .auth_utils import get_model_from_request
from litellm.proxy.management_endpoints.common_utils import _is_team_model_overrides_enabled
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level import in auth module

Per CLAUDE.md, top-level imports are preferred. However, this creates a direct import from management_endpoints into the auth module. If common_utils ever imports from auth_checks (it currently doesn't, but key_management_endpoints.py already does a deferred from litellm.proxy.auth.auth_checks import compute_effective_team_models at line 921), this could lead to a circular import risk. Consider whether this should remain top-level or use a deferred import like the callers in key_management_endpoints.py do.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
Expand Down Expand Up @@ -273,6 +274,7 @@ async def common_checks(
team_object=team_object,
llm_router=llm_router,
team_model_aliases=valid_token.team_model_aliases if valid_token else None,
valid_token=valid_token,
):
raise ProxyException(
message=f"Team not allowed to access model. Team={team_object.team_id}, Model={_model}. Allowed team models = {team_object.models}",
Expand Down Expand Up @@ -2557,23 +2559,51 @@ def can_org_access_model(
)


def compute_effective_team_models(
team_default_models: List[str],
team_member_models: List[str],
) -> List[str]:
"""Union of team defaults and per-user overrides, deduplicated."""
return list(set(team_default_models) | set(team_member_models))


async def can_team_access_model(
model: Union[str, List[str]],
team_object: Optional[LiteLLM_TeamTable],
llm_router: Optional[Router],
team_model_aliases: Optional[Dict[str, str]] = None,
valid_token: Optional[UserAPIKeyAuth] = None,
) -> Literal[True]:
"""
Returns True if the team can access a specific model.

1. First checks native team-level model permissions (current implementation)
2. If not allowed natively, falls back to access_group_ids on the team
"""
models_to_check: List[str] = team_object.models if team_object else []
if _is_team_model_overrides_enabled() and valid_token:
# Compute effective models: team defaults + per-user overrides
effective_models = compute_effective_team_models(
team_default_models=valid_token.team_default_models,
team_member_models=valid_token.team_member_models,
)

# If effective_models is empty, and feature is enabled, deny access
if len(effective_models) == 0:
raise ProxyException(
message=f"Team not allowed to access model. No models available for user in this team. Model={model}.",
type=ProxyErrorTypes.team_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
)

models_to_check = effective_models
Comment on lines +2583 to +2600
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Override mode silently ignores team_object.models

When _is_team_model_overrides_enabled() is True and valid_token is present, the original team_object.models list is completely replaced by effective_models (the union of team_default_models and team_member_models). This means if a team admin sets models on the team but forgets to also populate default_models, the effective models will be empty and all requests will be denied — even though team_object.models has valid entries.

Consider falling back to team_object.models when the effective models are empty, or making default_models derive from models when unset, to avoid a confusing misconfiguration cliff.


try:
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=team_object.models if team_object else [],
models=models_to_check,
team_model_aliases=team_model_aliases,
team_id=team_object.team_id if team_object else None,
object_type="team",
Expand Down
48 changes: 34 additions & 14 deletions litellm/proxy/management_endpoints/common_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import os
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

from litellm._logging import verbose_proxy_logger
from litellm.caching import DualCache
Expand Down Expand Up @@ -312,6 +313,7 @@ async def _upsert_budget_and_membership(
user_api_key_dict: UserAPIKeyAuth,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
models: Optional[List[str]] = None,
):
"""
Helper function to Create/Update or Delete the budget within the team membership
Expand All @@ -324,15 +326,20 @@ async def _upsert_budget_and_membership(
user_api_key_dict: User API Key dictionary containing user information
tpm_limit: Tokens per minute limit for the team member
rpm_limit: Requests per minute limit for the team member
models: Model names the team member is allowed to call (per-user overrides)

If max_budget, tpm_limit, and rpm_limit are all None, the user's budget is removed from the team membership.
If any of these values exist, a budget is updated or created and linked to the team membership.
"""
if max_budget is None and tpm_limit is None and rpm_limit is None:
# disconnect the budget since all limits are None
update_data: Dict[str, Any] = {"litellm_budget_table": {"disconnect": True}}
if models is not None:
update_data["models"] = models

await tx.litellm_teammembership.update(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
data={"litellm_budget_table": {"disconnect": True}},
data=update_data,
)
return

Expand All @@ -352,6 +359,25 @@ async def _upsert_budget_and_membership(
data=create_data,
include={"team_membership": True},
)

update_payload: Dict[str, Any] = {
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
},
}
if models is not None:
update_payload["models"] = models

create_payload: Dict[str, Any] = {
"user_id": user_id,
"team_id": team_id,
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
},
}
if models is not None:
create_payload["models"] = models

# upsert the team membership with the new/updated budget
await tx.litellm_teammembership.upsert(
where={
Expand All @@ -361,18 +387,8 @@ async def _upsert_budget_and_membership(
}
},
data={
"create": {
"user_id": user_id,
"team_id": team_id,
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
},
},
"update": {
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
},
},
"create": create_payload,
"update": update_payload,
},
)

Expand Down Expand Up @@ -429,3 +445,7 @@ def _update_metadata_fields(updated_kv: dict) -> None:
for field in LiteLLM_ManagementEndpoint_MetadataFields:
if field in updated_kv and updated_kv[field] is not None:
_update_metadata_field(updated_kv=updated_kv, field_name=field)


def _is_team_model_overrides_enabled() -> bool:
return os.getenv("LITELLM_TEAM_MODEL_OVERRIDES", "false").lower() == "true"
92 changes: 92 additions & 0 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,65 @@ async def _check_team_key_limits(
)


async def _validate_key_models_against_effective_team_models(
team_id: str,
user_id: Optional[str],
data: Union[GenerateKeyRequest, UpdateKeyRequest],
team_table: LiteLLM_TeamTableCachedObj,
prisma_client: PrismaClient,
) -> None:
"""
Validate that the requested models for a key are a subset of the effective team models.
Effective models = team.default_models ∪ membership.models
"""
from litellm.proxy.management_endpoints.common_utils import (
_is_team_model_overrides_enabled,
)

if not _is_team_model_overrides_enabled():
return

# 1. Fetch team membership if user_id is provided
member_models: List[str] = []
if user_id:
membership = await prisma_client.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}}
Comment on lines +914 to +915
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct DB query bypasses caching layer

This find_unique call goes directly to Prisma instead of using a cached helper. While this is in a management endpoint (not the critical request path), there is already a get_team_member_object or similar pattern used elsewhere. If team membership data is cached, this raw query will miss the cache and could return stale-inconsistent results compared to the auth path which reads team_member_models from the SQL join in utils.py.

)
if membership:
member_models = membership.models or []

# 2. Compute effective models
from litellm.proxy.auth.auth_checks import compute_effective_team_models

effective_models = compute_effective_team_models(
team_default_models=team_table.default_models or [],
team_member_models=member_models,
)

# 3. Step 6b: If effective models are empty, deny access (empty list != all access)
if not effective_models:
raise HTTPException(
status_code=403,
detail={
"error": f"No models available for User={user_id} in Team={team_id}. Admins must set 'default_models' on the team or per-user 'models' overrides."
},
)

# 4. Step 6b: If data.models is empty, default to effective models
if not data.models:
data.models = effective_models
Comment on lines +938 to +939
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty data.models silently overwritten with effective models

When data.models is falsy (empty list or None), this code silently assigns effective_models to data.models. This means if a user creates a key without specifying models, the key will be locked to whatever the current effective models are at creation time. If the team's default_models or member's models change later, previously created keys will still have the old snapshot. This may be intentional, but it's a subtle behavior worth documenting — keys don't dynamically inherit updated effective models.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

else:
# Verify requested models are a subset of effective models
for m in data.models:
if m not in effective_models:
raise HTTPException(
status_code=403,
detail={
"error": f"Model '{m}' is not available for this user in Team={team_id}. Available models = {effective_models}"
},
)


async def _check_project_key_limits(
project_id: str,
data: Union[GenerateKeyRequest, UpdateKeyRequest],
Expand Down Expand Up @@ -1201,6 +1260,13 @@ async def generate_key_fn(
data=data,
prisma_client=prisma_client,
)
await _validate_key_models_against_effective_team_models(
team_id=data.team_id,
user_id=data.user_id,
data=data,
team_table=team_table,
prisma_client=prisma_client,
)

# Validate key against project limits if project_id is set
if data.project_id is not None:
Expand Down Expand Up @@ -1354,6 +1420,13 @@ async def generate_service_account_key_fn(
data=data,
prisma_client=prisma_client,
)
await _validate_key_models_against_effective_team_models(
team_id=data.team_id,
user_id=data.user_id,
data=data,
team_table=team_table,
prisma_client=prisma_client,
)

key_generation_check(
team_table=team_table,
Expand Down Expand Up @@ -1887,6 +1960,25 @@ async def update_key_fn(
prisma_client=prisma_client,
)

# Step 6d: Key update validation against effective models
if data.models is not None and (data.team_id or existing_key_row.team_id):
team_id_to_check = data.team_id or existing_key_row.team_id
if team_obj is None or team_obj.team_id != team_id_to_check:
team_obj = await get_team_object(
team_id=team_id_to_check,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
await _validate_key_models_against_effective_team_models(
team_id=team_id_to_check,
user_id=data.user_id or existing_key_row.user_id,
data=data,
team_table=team_obj,
prisma_client=prisma_client,
)

# Validate key against project limits if project_id is being set
_project_id_to_check = getattr(data, "project_id", None) or getattr(
existing_key_row, "project_id", None
Expand Down
Loading
Loading