diff --git a/docs/my-website/docs/proxy/model_access.md b/docs/my-website/docs/proxy/model_access.md index 961207cad5a..494ce5f9c1b 100644 --- a/docs/my-website/docs/proxy/model_access.md +++ b/docs/my-website/docs/proxy/model_access.md @@ -113,6 +113,151 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ ### [API Reference](https://litellm-api.up.railway.app/#/team%20management/new_team_team_new_post) +## **Per-Member Model Overrides (Team-Scoped Defaults)** + +:::info + +Requires `TEAM_MODEL_OVERRIDES=true` environment variable or `litellm.team_model_overrides_enabled = True`. + +::: + +By default, every team member can access all models in `team.models`. With per-member model overrides, you can: + +- Set **`default_models`** on a team — the models every member gets by default +- Set **`models`** on individual team members — additional models only they can access + +A member's **effective models** = `default_models` ∪ `member.models`. If neither is set, falls back to `team.models` (full backward compatibility). + +### Enable the Feature + +Add to your `config.yaml`: + +```yaml +environment_variables: + TEAM_MODEL_OVERRIDES: "true" +``` + +### 1. Create a Team with Default Models + +```shell +curl -L 'http://localhost:4000/team/new' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "team_alias": "engineering", + "models": ["gpt-4", "gpt-4o-mini", "gpt-4o"], + "default_models": ["gpt-4o-mini"] + }' +``` + +- `models` — the full pool of models the team is allowed to use +- `default_models` — the subset every member gets by default (must be a subset of `models`) + +### 2. Add Members with Per-User Overrides + +```shell +# Alice gets the default (gpt-4o-mini only) +curl -L 'http://localhost:4000/team/member_add' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "team_id": "", + "member": {"role": "user", "user_id": "alice"} + }' + +# Bob gets gpt-4o in addition to the default +curl -L 'http://localhost:4000/team/member_add' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "team_id": "", + "member": {"role": "user", "user_id": "bob", "models": ["gpt-4o"]} + }' +``` + +| Member | Override | Effective Models | +|--------|----------|-----------------| +| Alice | none | `["gpt-4o-mini"]` | +| Bob | `["gpt-4o"]` | `["gpt-4o-mini", "gpt-4o"]` | + +### 3. Generate Keys and Test + +```shell +# Generate key for Bob +curl -L 'http://localhost:4000/key/generate' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"team_id": "", "user_id": "bob"}' +``` + + + + +```shell +curl -L 'http://localhost:4000/chat/completions' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}' +``` + +Returns `200 OK` — `gpt-4o` is in Bob's effective set. + + + + +```shell +curl -L 'http://localhost:4000/chat/completions' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' +``` + +Returns `401 Unauthorized` — `gpt-4` is in the team pool but not in Bob's effective set. + + + + +### 4. Update Member Overrides + +```shell +# Add gpt-4 to Bob's overrides +curl -L 'http://localhost:4000/team/member_update' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "team_id": "", + "user_id": "bob", + "models": ["gpt-4o", "gpt-4"] + }' + +# Remove all overrides (Bob falls back to default_models only) +curl -L 'http://localhost:4000/team/member_update' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "team_id": "", + "user_id": "bob", + "models": [] + }' +``` + +### Validation Rules + +| Rule | Error | +|------|-------| +| `default_models` must be a subset of `team.models` | `400` on `/team/new` and `/team/update` | +| Member `models` must be a subset of `team.models` | `400` on `/team/member_add` and `/team/member_update` | +| Key `models` must be a subset of effective models | `403` on `/key/generate` | +| Narrowing `team.models` auto-prunes stale `default_models` | Automatic on `/team/update` | + +### Backward Compatibility + +When the feature flag is off **or** when neither `default_models` nor member `models` is configured: + +- `get_effective_team_models()` returns `team.models` unchanged +- All existing teams and keys work exactly as before +- Zero extra database queries on the auth hot path + ## **View Available Fallback Models** diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260321000001_add_team_model_overrides/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260321000001_add_team_model_overrides/migration.sql new file mode 100644 index 00000000000..2f56477c9c1 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260321000001_add_team_model_overrides/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: Add default_models to LiteLLM_TeamTable +ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "default_models" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- AlterTable: Add models to LiteLLM_TeamMembership +ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN IF NOT EXISTS "models" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index a2c83295403..3375260c3dc 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -142,6 +142,7 @@ model LiteLLM_TeamTable { policies String[] @default([]) model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team + default_models String[] @default([]) // NEW: team-wide defaults litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id]) litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) @@ -595,6 +596,7 @@ model LiteLLM_TeamMembership { team_id String spend Float @default(0.0) budget_id String? + models String[] @default([]) // NEW: per-user model overrides litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) } diff --git a/litellm/__init__.py b/litellm/__init__.py index 7f72e0b0e89..b0f34ee5a31 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -218,6 +218,7 @@ use_chat_completions_url_for_anthropic_messages: bool = bool( os.getenv("LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES", False) ) # When True, routes OpenAI /v1/messages requests to chat/completions instead of the Responses API retry = True +team_model_overrides_enabled = os.getenv("TEAM_MODEL_OVERRIDES", "").lower() == "true" ### AUTH ### api_key: Optional[str] = None openai_key: Optional[str] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 91a953c217e..cb153eef7b5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1611,6 +1611,16 @@ class Member(MemberBase): ] = Field( description="The role of the user within the team. 'admin' users can manage team settings and members, 'user' is a regular team member" ) + models: Optional[List[str]] = Field( + default=None, + description="Specific models this member can access within the team. If provided, these will be used in addition to the team's default models.", + ) + tpm_limit: Optional[int] = Field( + default=None, description="Tokens per minute limit for this team member" + ) + rpm_limit: Optional[int] = Field( + default=None, description="Requests per minute limit for this team member" + ) class OrgMember(MemberBase): @@ -1642,6 +1652,7 @@ class TeamBase(LiteLLMPydanticObjectBase): blocked: bool = False router_settings: Optional[dict] = None access_group_ids: Optional[List[str]] = None + default_models: List[str] = [] class NewTeamRequest(TeamBase): @@ -1719,6 +1730,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): model_aliases: Optional[dict] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None + default_models: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None team_member_budget: Optional[float] = None team_member_budget_duration: Optional[str] = None @@ -2387,6 +2399,8 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken): team_alias: Optional[str] = None team_tpm_limit: Optional[int] = None team_rpm_limit: Optional[int] = None + team_member_models: Optional[List[str]] = None + team_default_models: Optional[List[str]] = None team_max_budget: Optional[float] = None team_soft_budget: Optional[float] = None team_models: List = [] @@ -3607,6 +3621,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]: @@ -3729,6 +3744,7 @@ class TeamMemberDeleteRequest(MemberDeleteRequest): class TeamMemberUpdateRequest(TeamMemberDeleteRequest): max_budget_in_team: Optional[float] = None role: Optional[Literal["admin", "user"]] = None + models: Optional[List[str]] = None tpm_limit: Optional[int] = Field( default=None, description="Tokens per minute limit for this team member" ) @@ -3739,6 +3755,7 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest): class TeamMemberUpdateResponse(MemberUpdateResponse): team_id: str + models: Optional[List[str]] = None max_budget_in_team: Optional[float] = None tpm_limit: Optional[int] = None rpm_limit: Optional[int] = None diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1aa14fff574..4c099285d13 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -9,6 +9,7 @@ Run checks for: 3. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget """ import asyncio +import os import re import time from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast @@ -409,20 +410,16 @@ async def common_checks( # noqa: PLR0915 # 2. If team can call model if _model and team_object: with tracer.trace("litellm.proxy.auth.common_checks.can_team_access_model"): - if not await can_team_access_model( + # can_team_access_model returns Literal[True] or raises ProxyException + await can_team_access_model( model=_model, team_object=team_object, llm_router=llm_router, team_model_aliases=valid_token.team_model_aliases if valid_token else None, - ): - raise ProxyException( - message=f"Team not allowed to access model. Team={team_object.team_id}, Model={_model}. Allowed team models = {team_object.models}", - type=ProxyErrorTypes.team_model_access_denied, - param="model", - code=status.HTTP_401_UNAUTHORIZED, - ) + valid_token=valid_token, + ) # Require trace id for agent keys when agent has require_trace_id_on_calls_by_agent if valid_token is not None and valid_token.agent_id: @@ -2690,11 +2687,76 @@ def can_org_access_model( ) +def compute_effective_models( + team_defaults: List[str], + member_models: List[str], + team_pool: List[str], +) -> List[str]: + """ + Core computation shared by the auth hot-path and key-generation. + + effective = union(team_defaults, member_models), capped by team_pool. + - If neither defaults nor overrides are set, falls back to team_pool (backward compat). + - If cap empties the list (all stale), falls back to team_pool (NOT [] which = allow-all). + - team_pool=[] means "allow all" — cap is skipped. + """ + effective = list(set(team_defaults + member_models)) + + if not effective: + return team_pool + + if team_pool: + effective = [m for m in effective if m in set(team_pool)] + if not effective: + return team_pool + + return effective + + +def get_effective_team_models( + team_object: Optional[LiteLLM_TeamTable], + valid_token: Optional[UserAPIKeyAuth] = None, +) -> List[str]: + """ + Returns the effective list of models for a team member. + The union of: + - team_object.default_models (OR valid_token.team_default_models if available) + - team_membership.models (OR valid_token.team_member_models if available) + + Capped by team_object.models. Falls back to team_object.models when empty. + """ + if not ( + litellm.team_model_overrides_enabled + or os.getenv("TEAM_MODEL_OVERRIDES", "").lower() == "true" + ): + return team_object.models if team_object else [] + + # Get from team defaults — prefer team_object (authoritative, fresh from DB/cache) + # over valid_token (snapshot from key creation time, may be stale). + # Use `is not None` instead of truthiness so that an explicit empty list [] + # (meaning "no defaults") is not confused with "field missing". + team_defaults: List[str] = [] + if team_object and team_object.default_models is not None: + team_defaults = team_object.default_models + elif valid_token and valid_token.team_default_models is not None: + team_defaults = valid_token.team_default_models + + # Get from member specific overrides + member_models: List[str] = [] + if valid_token and valid_token.team_member_models is not None: + member_models = valid_token.team_member_models + + team_pool = team_object.models if team_object else [] + + return compute_effective_models(team_defaults, member_models, team_pool) + + 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. @@ -2702,11 +2764,12 @@ async def can_team_access_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 """ + effective_models = get_effective_team_models(team_object, valid_token) try: return _can_object_call_model( model=model, llm_router=llm_router, - models=team_object.models if team_object else [], + models=effective_models, team_model_aliases=team_model_aliases, team_id=team_object.team_id if team_object else None, object_type="team", diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index efc42d3355c..a8a6569278c 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from litellm._logging import verbose_proxy_logger from litellm.caching import DualCache @@ -354,6 +354,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 @@ -366,35 +367,69 @@ 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: Specific models this member can access within the team. - 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, tpm_limit, rpm_limit, and models are all None, the budget is disconnected + (but existing model overrides are preserved — models=None means "not specified"). + If any of these values exist, a budget is updated or created and linked to the team membership, and models are updated. + To explicitly clear model overrides, pass models=[]. """ - if max_budget is None and tpm_limit is None and rpm_limit is None: - # disconnect the budget since all limits are None - await tx.litellm_teammembership.update( - where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}}, - data={"litellm_budget_table": {"disconnect": True}}, - ) + if ( + max_budget is None + and tpm_limit is None + and rpm_limit is None + and models is None + ): + # Nothing to change — only disconnect budget if one was actually linked. + # Do NOT touch models (models=None means "not specified", not "clear"). + # Use upsert (not update) because members added without budget/models + # may not have a LiteLLM_TeamMembership row yet. + if existing_budget_id is not None: + await tx.litellm_teammembership.upsert( + where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}}, + data={ + "create": {"user_id": user_id, "team_id": team_id}, + "update": {"litellm_budget_table": {"disconnect": True}}, + }, + ) return - # create a new budget - create_data: Dict[str, Any] = { - "created_by": user_api_key_dict.user_id or "", - "updated_by": user_api_key_dict.user_id or "", - } - if max_budget is not None: - create_data["max_budget"] = max_budget - if tpm_limit is not None: - create_data["tpm_limit"] = tpm_limit - if rpm_limit is not None: - create_data["rpm_limit"] = rpm_limit + _budget_id = existing_budget_id + if max_budget is not None or tpm_limit is not None or rpm_limit is not None: + # create a new budget + create_data: Dict[str, Any] = { + "created_by": user_api_key_dict.user_id or "", + "updated_by": user_api_key_dict.user_id or "", + } + if max_budget is not None: + create_data["max_budget"] = max_budget + if tpm_limit is not None: + create_data["tpm_limit"] = tpm_limit + if rpm_limit is not None: + create_data["rpm_limit"] = rpm_limit + + new_budget = await tx.litellm_budgettable.create( + data=create_data, + include={"team_membership": True}, + ) + _budget_id = new_budget.budget_id + + # upsert the team membership with the new/updated budget and models + membership_create_data: Dict[str, Any] = { + "user_id": user_id, + "team_id": team_id, + } + membership_update_data: Dict[str, Any] = {} + if _budget_id: + budget_connect = { + "litellm_budget_table": {"connect": {"budget_id": _budget_id}} + } + membership_create_data.update(budget_connect) + membership_update_data.update(budget_connect) + if models is not None: + membership_create_data["models"] = models + membership_update_data["models"] = models - new_budget = await tx.litellm_budgettable.create( - data=create_data, - include={"team_membership": True}, - ) - # upsert the team membership with the new/updated budget await tx.litellm_teammembership.upsert( where={ "user_id_team_id": { @@ -403,18 +438,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": membership_create_data, + "update": membership_update_data, }, ) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 831922ec3f9..8365a1d4818 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -41,6 +41,7 @@ from litellm.proxy._experimental.mcp_server.db import ( from litellm.proxy._types import * from litellm.proxy._types import LiteLLM_VerificationToken from litellm.proxy.auth.auth_checks import ( + compute_effective_models, _delete_cache_key_object, can_team_access_model, get_org_object, @@ -636,6 +637,54 @@ async def _common_key_generation_helper( # noqa: PLR0915 data_json = data.model_dump(exclude_unset=True, exclude_none=True) # type: ignore + # [TEAM MODEL OVERRIDES] Handle effective team models for the key + if ( + litellm.team_model_overrides_enabled + or os.getenv("TEAM_MODEL_OVERRIDES", "").lower() == "true" + ) and team_table is not None: + # Read member models from LiteLLM_TeamMembership table (authoritative source), + # NOT from members_with_roles JSON blob which can be stale after /team/member_update. + # Note: This is a management endpoint (/key/generate), not the hot auth path. + # The hot path (/chat/completions) uses the SQL view join with zero extra queries. + member_models: List[str] = [] + if data.user_id and prisma_client is not None: + _membership = await prisma_client.db.litellm_teammembership.find_unique( + where={ + "user_id_team_id": { + "user_id": data.user_id, + "team_id": team_table.team_id, + } + } + ) + if _membership is not None: + member_models = _membership.models or [] + team_default_models = getattr(team_table, "default_models", None) or [] + team_pool = team_table.models or [] + effective_models = compute_effective_models( + team_defaults=team_default_models, + member_models=member_models, + team_pool=team_pool, + ) + + if effective_models: + # if 'all-team-models' was requested, restrict it to the effective models + if "all-team-models" in (data.models or []): + data_json["models"] = effective_models + # if explicit models were requested, validate they're a subset of effective set + elif data.models: + disallowed = set(data.models) - set(effective_models) + if disallowed: + raise HTTPException( + status_code=403, + detail={ + "error": f"Requested models not in user's effective team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Effective models: {sorted(effective_models)}" + }, + ) + # if NO models was requested, runtime auth will compute effective models + # from the SQL view join (tm.models + t.default_models), so nothing to store here + data_json = handle_key_type(data, data_json) # if we get max_budget passed to /key/generate, then use it as key_max_budget. Since generate_key_helper_fn is used to make new users diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3643373be65..fcc8d1a0a91 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -813,6 +813,22 @@ async def new_team( # noqa: PLR0915 }, ) + # Validate default_models is a subset of team models (prevent privilege escalation). + # When data.models is empty/[] (unrestricted team), skip validation — by design, + # team.models=[] means "allow all" so any default_models is a valid subset. + # At runtime, compute_effective_models caps effective set by team.models. + if data.default_models and data.models: + disallowed = set(data.default_models) - set(data.models) + if disallowed: + raise HTTPException( + status_code=400, + detail={ + "error": f"default_models must be a subset of team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Team models: {sorted(data.models)}" + }, + ) + # Check if license is over limit total_teams = await prisma_client.db.litellm_teamtable.count() if total_teams and _license_check.is_team_count_over_limit( @@ -1407,6 +1423,25 @@ async def update_team( # noqa: PLR0915 detail={"error": f"Team not found, passed team_id={data.team_id}"}, ) + # Validate default_models is a subset of team models (prevent privilege escalation) + if data.default_models: + team_models = ( + data.models + if data.models is not None + else (existing_team_row.models or []) + ) + if team_models: + disallowed = set(data.default_models) - set(team_models) + if disallowed: + raise HTTPException( + status_code=400, + detail={ + "error": f"default_models must be a subset of team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Team models: {sorted(team_models)}" + }, + ) + if data.soft_budget is not None: max_budget_to_check = ( data.max_budget @@ -1494,6 +1529,23 @@ async def update_team( # noqa: PLR0915 updated_kv = data.json(exclude_unset=True) + # When team.models is being changed, prune existing default_models + # to prevent stale over-permissive defaults (privilege escalation). + # Must inject into updated_kv directly (not data) because + # data.json(exclude_unset=True) skips fields not set during __init__. + if "models" in updated_kv and "default_models" not in updated_kv: + existing_defaults = existing_team_row.default_models or [] + if existing_defaults: + new_models = updated_kv["models"] or [] + if not new_models: + # team.models=[] means "allow all" — clear default_models + # so get_effective_team_models falls back to [] (allow all) + updated_kv["default_models"] = [] + else: + pruned = [m for m in existing_defaults if m in set(new_models)] + if pruned != existing_defaults: + updated_kv["default_models"] = pruned + # Check budget_duration and budget_reset_at _set_budget_reset_at(data, updated_kv) @@ -1764,6 +1816,29 @@ async def _process_team_members( else None ) + # Validate member model overrides are within team.models (prevent privilege escalation) + team_models = ( + complete_team_data.models + if hasattr(complete_team_data, "models") + and isinstance(complete_team_data.models, list) + else [] + ) + members_to_validate = ( + [data.member] if isinstance(data.member, Member) else data.member + ) + for member in members_to_validate: + if member.models and team_models: + disallowed = set(member.models) - set(team_models) + if disallowed: + raise HTTPException( + status_code=400, + detail={ + "error": f"Member model overrides must be a subset of team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Team models: {sorted(team_models)}" + }, + ) + if isinstance(data.member, Member): try: updated_user, updated_tm = await add_new_member( @@ -1774,6 +1849,7 @@ async def _process_team_members( litellm_proxy_admin_name=litellm_proxy_admin_name, team_id=data.team_id, default_team_budget_id=default_team_budget_id, + team_models=team_models, ) except Exception as e: raise HTTPException( @@ -1798,6 +1874,7 @@ async def _process_team_members( litellm_proxy_admin_name=litellm_proxy_admin_name, team_id=data.team_id, default_team_budget_id=default_team_budget_id, + team_models=team_models, ) except Exception as e: raise HTTPException( @@ -2331,7 +2408,7 @@ async def team_member_delete( response_model=TeamMemberUpdateResponse, ) @management_endpoint_wrapper -async def team_member_update( +async def team_member_update( # noqa: PLR0915 data: TeamMemberUpdateRequest, http_request: Request, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -2424,6 +2501,19 @@ async def team_member_update( identified_budget_id = tm.budget_id break + ### validate member model overrides are within team.models (when restricted) + if data.models and existing_team_row.models: + disallowed = set(data.models) - set(existing_team_row.models) + if disallowed: + raise HTTPException( + status_code=400, + detail={ + "error": f"Member model overrides must be a subset of team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Team models: {sorted(existing_team_row.models)}" + }, + ) + ### upsert new budget async with prisma_client.db.tx() as tx: await _upsert_budget_and_membership( @@ -2435,18 +2525,37 @@ async def team_member_update( user_api_key_dict=user_api_key_dict, tpm_limit=data.tpm_limit, rpm_limit=data.rpm_limit, + models=data.models, ) ### update team member role - if data.role is not None: + # Resolve the effective models for this member (from the authoritative + # LiteLLM_TeamMembership table) so we can: (a) keep the members_with_roles + # JSON in sync, and (b) return the actual stored state in the response. + stored_models = data.models + if stored_models is None: + _tm_row = await prisma_client.db.litellm_teammembership.find_unique( + where={ + "user_id_team_id": { + "user_id": received_user_id, + "team_id": data.team_id, + } + } + ) + stored_models = (_tm_row.models or []) if _tm_row is not None else [] + + if data.role is not None or data.models is not None: team_members: List[Member] = [] for member in team_table.members_with_roles: if member.user_id == received_user_id: team_members.append( Member( user_id=member.user_id, - role=data.role, + role=data.role or member.role, user_email=data.user_email or member.user_email, + models=stored_models, + tpm_limit=data.tpm_limit if data.tpm_limit is not None else getattr(member, "tpm_limit", None), + rpm_limit=data.rpm_limit if data.rpm_limit is not None else getattr(member, "rpm_limit", None), ) ) else: @@ -2464,6 +2573,7 @@ async def team_member_update( team_id=data.team_id, user_id=received_user_id, user_email=data.user_email, + models=stored_models, max_budget_in_team=data.max_budget_in_team, tpm_limit=data.tpm_limit, rpm_limit=data.rpm_limit, diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index 7d485fdebde..15385b2a42b 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -2,7 +2,7 @@ ## Helper utils for the management endpoints (keys/users/teams) from datetime import datetime from functools import wraps -from typing import Optional, Tuple +from typing import List, Optional, Tuple from fastapi import HTTPException, Request @@ -148,6 +148,7 @@ async def add_new_member( user_api_key_dict: UserAPIKeyAuth, litellm_proxy_admin_name: str, default_team_budget_id: Optional[str] = None, + team_models: Optional[List[str]] = None, ) -> Tuple[LiteLLM_UserTable, Optional[LiteLLM_TeamMembership]]: """ Add a new member to a team @@ -208,28 +209,60 @@ async def add_new_member( # Check if trying to set a budget for team member - if max_budget_in_team is not None: + if ( + max_budget_in_team is not None + or new_member.tpm_limit is not None + or new_member.rpm_limit is not None + ): # create a new budget item for this member + _budget_create_data = { + "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, + "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, + } + if max_budget_in_team is not None: + _budget_create_data["max_budget"] = max_budget_in_team + if new_member.tpm_limit is not None: + _budget_create_data["tpm_limit"] = new_member.tpm_limit + if new_member.rpm_limit is not None: + _budget_create_data["rpm_limit"] = new_member.rpm_limit + response = await prisma_client.db.litellm_budgettable.create( - data={ - "max_budget": max_budget_in_team, - "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, - "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, - } + data=_budget_create_data # type: ignore ) _budget_id = response.budget_id else: _budget_id = default_team_budget_id - if _budget_id and returned_user is not None and returned_user.user_id is not None: + if ( + (_budget_id or new_member.models) + and returned_user is not None + and returned_user.user_id is not None + ): + membership_create_data = { + "team_id": team_id, + "user_id": returned_user.user_id, + } + if _budget_id: + membership_create_data["budget_id"] = _budget_id + if new_member.models: + # Defense-in-depth: validate member models are within team models + if team_models: + disallowed = set(new_member.models) - set(team_models) + if disallowed: + raise HTTPException( + status_code=400, + detail={ + "error": f"Member model overrides must be a subset of team models. " + f"Disallowed: {sorted(disallowed)}. " + f"Team models: {sorted(team_models)}" + }, + ) + membership_create_data["models"] = new_member.models + _returned_team_membership = ( await prisma_client.db.litellm_teammembership.create( - data={ - "team_id": team_id, - "user_id": returned_user.user_id, - "budget_id": _budget_id, - }, + data=membership_create_data, # type: ignore include={"litellm_budget_table": True}, ) ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 46be6b31e1f..23802098158 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -142,6 +142,7 @@ model LiteLLM_TeamTable { policies String[] @default([]) model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team + default_models String[] @default([]) // NEW: team-wide defaults litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id]) litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) @@ -599,6 +600,7 @@ model LiteLLM_TeamMembership { team_id String spend Float @default(0.0) budget_id String? + models String[] @default([]) // NEW: per-user model overrides litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) } diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 7954f0b6460..cdf5fe70ca3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -2961,6 +2961,7 @@ class PrismaClient: t.tpm_limit AS team_tpm_limit, t.rpm_limit AS team_rpm_limit, t.models AS team_models, + t.default_models AS team_default_models, t.metadata AS team_metadata, t.blocked AS team_blocked, t.team_alias AS team_alias, @@ -2969,6 +2970,7 @@ class PrismaClient: t.object_permission_id AS team_object_permission_id, t.organization_id as org_id, tm.spend AS team_member_spend, + tm.models AS team_member_models, m.aliases AS team_model_aliases, -- Added comma to separate b.* columns b.max_budget AS litellm_budget_table_max_budget, diff --git a/schema.prisma b/schema.prisma index fde9a466a28..aa56236ed39 100644 --- a/schema.prisma +++ b/schema.prisma @@ -142,6 +142,7 @@ model LiteLLM_TeamTable { policies String[] @default([]) model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team + default_models String[] @default([]) // NEW: team-wide defaults litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id]) litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) @@ -590,6 +591,7 @@ model LiteLLM_TeamMembership { team_id String spend Float @default(0.0) budget_id String? + models String[] @default([]) // NEW: per-user model overrides litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) } diff --git a/tests/proxy_unit_tests/test_team_model_overrides.py b/tests/proxy_unit_tests/test_team_model_overrides.py new file mode 100644 index 00000000000..4b7afbd8810 --- /dev/null +++ b/tests/proxy_unit_tests/test_team_model_overrides.py @@ -0,0 +1,94 @@ +import sys +import os +import pytest + +# Add the parent directory to the system path to import litellm +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +import litellm +from litellm.proxy._types import UserAPIKeyAuth, LiteLLM_TeamTable +from litellm.proxy.auth.auth_checks import ( + can_team_access_model, + get_effective_team_models, +) + + +@pytest.mark.asyncio +async def test_get_effective_team_models(): + original_flag = litellm.team_model_overrides_enabled + original_env = os.environ.pop("TEAM_MODEL_OVERRIDES", None) + try: + litellm.team_model_overrides_enabled = True + + # Case 1: No overrides, should return team.models + team = LiteLLM_TeamTable(team_id="t1", models=["m1"]) + assert get_effective_team_models(team) == ["m1"] + + # Case 2: Team defaults exist (d1 must be in team.models pool) + team = LiteLLM_TeamTable(team_id="t1", models=["m1", "d1"], default_models=["d1"]) + assert set(get_effective_team_models(team)) == {"d1"} + + # Case 3: Team defaults + Member overrides (all in team.models pool) + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "mo1"], default_models=["d1"] + ) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + assert set(get_effective_team_models(team, token)) == {"d1", "mo1"} + + # Case 4: No team object (should use token values if available) + token.team_default_models = ["td1"] + assert set(get_effective_team_models(None, token)) == {"td1", "mo1"} + + # Case 5: Feature disabled — also ensure env var is cleared + litellm.team_model_overrides_enabled = False + os.environ.pop("TEAM_MODEL_OVERRIDES", None) + assert get_effective_team_models(team, token) == ["m1", "d1", "mo1"] + finally: + litellm.team_model_overrides_enabled = original_flag + if original_env is not None: + os.environ["TEAM_MODEL_OVERRIDES"] = original_env + + +@pytest.mark.asyncio +async def test_can_team_access_model_with_overrides(): + original_flag = litellm.team_model_overrides_enabled + try: + litellm.team_model_overrides_enabled = True + + # Team pool includes m1, d1, g1. default_models=["d1"]. + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "g1"], default_models=["d1"] + ) + + # With only defaults, should NOT have access to m1 + with pytest.raises(Exception): + await can_team_access_model(model="m1", team_object=team, llm_router=None) + + # Should have access to d1 (it's a default) + assert ( + await can_team_access_model(model="d1", team_object=team, llm_router=None) + is True + ) + + # Member has extra access to g1 + token = UserAPIKeyAuth(team_member_models=["g1"]) + assert ( + await can_team_access_model( + model="g1", team_object=team, llm_router=None, valid_token=token + ) + is True + ) + assert ( + await can_team_access_model( + model="d1", team_object=team, llm_router=None, valid_token=token + ) + is True + ) + + # Should NOT have access to m1 + with pytest.raises(Exception): + await can_team_access_model( + model="m1", team_object=team, llm_router=None, valid_token=token + ) + finally: + litellm.team_model_overrides_enabled = original_flag diff --git a/tests/test_litellm/proxy/auth/test_team_model_overrides.py b/tests/test_litellm/proxy/auth/test_team_model_overrides.py new file mode 100644 index 00000000000..48192a3150d --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_team_model_overrides.py @@ -0,0 +1,367 @@ +""" +Tests for team-scoped default + per-user model overrides. + +Covers: +1. defaults_only → user can access default models, not others +2. defaults + overrides → user can access union of both +3. overrides only (no defaults) → user can access override models only +4. neither configured → falls back to team.models (backward compat, including [] = allow all) +5. key creation rejects models outside effective set → 403 +6. key creation with no models → defaults to effective set +7. remove override → next request for that model → 403 (revocation) +8. all-team-models key + overrides → restricted to effective set +9. cross-user isolation: User A overrides don't affect User B +10. access_group_ids fallback still works when effective models check fails +11. feature flag off → all new fields ignored, team.models used +12. empty default_models + empty member models + team.models=[] → allow all (backward compat) +""" + +import sys +import os +import pytest + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")) +) + +from unittest.mock import AsyncMock, patch + +import litellm +from litellm.proxy._types import UserAPIKeyAuth, LiteLLM_TeamTable +from litellm.proxy.auth.auth_checks import ( + can_team_access_model, + get_effective_team_models, +) + + +@pytest.fixture(autouse=True) +def enable_feature_flag(): + original = litellm.team_model_overrides_enabled + litellm.team_model_overrides_enabled = True + yield + litellm.team_model_overrides_enabled = original + + +# ── get_effective_team_models unit tests ───────────────────────────────────── + + +class TestGetEffectiveTeamModels: + def test_defaults_only(self): + """1. User with defaults only → can access default models.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "d1", "d2"], default_models=["d1", "d2"] + ) + result = get_effective_team_models(team) + assert set(result) == {"d1", "d2"} + + def test_defaults_plus_overrides(self): + """2. User with defaults + overrides → union of both.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "mo1"], default_models=["d1"] + ) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + result = get_effective_team_models(team, token) + assert set(result) == {"d1", "mo1"} + + def test_overrides_only_no_defaults(self): + """3. User with overrides only (no defaults) → can access override models.""" + team = LiteLLM_TeamTable(team_id="t1", models=["m1", "mo1", "mo2"]) + token = UserAPIKeyAuth(team_member_models=["mo1", "mo2"]) + result = get_effective_team_models(team, token) + assert set(result) == {"mo1", "mo2"} + + def test_neither_configured_fallback(self): + """4. Neither configured → falls back to team.models.""" + team = LiteLLM_TeamTable(team_id="t1", models=["m1", "m2"]) + result = get_effective_team_models(team) + assert result == ["m1", "m2"] + + def test_unrestricted_team_with_defaults(self): + """team.models=[] (allow all) + default_models set → members restricted to defaults. + This is by design: admin wants unrestricted team pool but limited member defaults.""" + team = LiteLLM_TeamTable(team_id="t1", models=[], default_models=["gpt-4"]) + result = get_effective_team_models(team) + # Cap is skipped (team_pool=[]), so defaults pass through + assert result == ["gpt-4"] + + def test_neither_configured_empty_team_models_allows_all(self): + """12. empty default_models + empty member models + team.models=[] → allow all.""" + team = LiteLLM_TeamTable(team_id="t1", models=[]) + result = get_effective_team_models(team) + assert result == [] # empty = allow all + + def test_cross_user_isolation(self): + """9. User A overrides don't affect User B.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "mo_a", "mo_b"], default_models=["d1"] + ) + token_a = UserAPIKeyAuth(team_member_models=["mo_a"]) + token_b = UserAPIKeyAuth(team_member_models=["mo_b"]) + result_a = get_effective_team_models(team, token_a) + result_b = get_effective_team_models(team, token_b) + assert set(result_a) == {"d1", "mo_a"} + assert set(result_b) == {"d1", "mo_b"} + assert "mo_a" not in result_b + assert "mo_b" not in result_a + + def test_feature_flag_off(self, monkeypatch): + """11. Feature flag off → all new fields ignored, team.models used.""" + litellm.team_model_overrides_enabled = False + monkeypatch.delenv("TEAM_MODEL_OVERRIDES", raising=False) + team = LiteLLM_TeamTable(team_id="t1", models=["m1"], default_models=["d1"]) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + result = get_effective_team_models(team, token) + assert result == ["m1"] + + def test_deduplication(self): + """Overlapping models are deduplicated.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "shared", "extra"], default_models=["shared"] + ) + token = UserAPIKeyAuth(team_member_models=["shared", "extra"]) + result = get_effective_team_models(team, token) + assert set(result) == {"shared", "extra"} + assert len(result) == 2 # no duplicates + + def test_no_team_object(self): + """No team object → empty list.""" + assert get_effective_team_models(None) == [] + + def test_no_team_object_with_token(self): + """No team object but token has defaults → uses token values.""" + token = UserAPIKeyAuth(team_default_models=["td1"], team_member_models=["mo1"]) + result = get_effective_team_models(None, token) + assert set(result) == {"td1", "mo1"} + + +# ── can_team_access_model integration tests ────────────────────────────────── + + +class TestCanTeamAccessModelWithOverrides: + @pytest.mark.asyncio + async def test_defaults_only_allowed(self): + """1. User with defaults only → can access default models.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "d1"], default_models=["d1"] + ) + assert await can_team_access_model( + model="d1", team_object=team, llm_router=None + ) + + @pytest.mark.asyncio + async def test_defaults_only_denied(self): + """1. User with defaults only → cannot access other team models.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "d1"], default_models=["d1"] + ) + with pytest.raises(Exception): + await can_team_access_model(model="m1", team_object=team, llm_router=None) + + @pytest.mark.asyncio + async def test_defaults_plus_overrides_allowed(self): + """2. User with defaults + overrides → can access union.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "mo1"], default_models=["d1"] + ) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + assert await can_team_access_model( + model="d1", team_object=team, llm_router=None, valid_token=token + ) + assert await can_team_access_model( + model="mo1", team_object=team, llm_router=None, valid_token=token + ) + + @pytest.mark.asyncio + async def test_defaults_plus_overrides_denied(self): + """2. User with overrides → cannot access models outside union.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "d1", "mo1"], default_models=["d1"] + ) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + with pytest.raises(Exception): + await can_team_access_model( + model="m2", team_object=team, llm_router=None, valid_token=token + ) + + @pytest.mark.asyncio + async def test_revocation_after_override_removal(self): + """7. Remove override → model access denied.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "d1", "mo1"], default_models=["d1"] + ) + # With override + token_with = UserAPIKeyAuth(team_member_models=["mo1"]) + assert await can_team_access_model( + model="mo1", team_object=team, llm_router=None, valid_token=token_with + ) + # After override removal (empty member models) + token_without = UserAPIKeyAuth(team_member_models=[]) + with pytest.raises(Exception): + await can_team_access_model( + model="mo1", + team_object=team, + llm_router=None, + valid_token=token_without, + ) + + @pytest.mark.asyncio + async def test_stale_override_capped_by_team_models(self): + """Stale member override for model removed from team.models → denied.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1"], default_models=["m1"] + ) + # Member has stale override for "m2" which is no longer in team.models + token = UserAPIKeyAuth(team_member_models=["m2"]) + # effective = union(["m1"], ["m2"]) capped by team.models=["m1"] → ["m1"] + with pytest.raises(Exception): + await can_team_access_model( + model="m2", team_object=team, llm_router=None, valid_token=token + ) + + @pytest.mark.asyncio + async def test_all_overrides_stale_does_not_grant_allow_all(self): + """P0: When ALL overrides are stale (capped out), must NOT return [] (allow all). + Should fall back to team.models to prevent privilege escalation.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1"] # no default_models + ) + # Member has ONLY stale overrides — none are in team.models + token = UserAPIKeyAuth(team_member_models=["stale1", "stale2"]) + result = get_effective_team_models(team, token) + # Should fall back to team.models=["m1"], NOT [] (allow all) + assert result == ["m1"] + # And specifically should NOT be empty (which means allow-all) + assert result != [] + # Member should be able to access m1 (team pool fallback) + assert await can_team_access_model( + model="m1", team_object=team, llm_router=None, valid_token=token + ) + # But not an arbitrary model + with pytest.raises(Exception): + await can_team_access_model( + model="random-model", team_object=team, llm_router=None, valid_token=token + ) + + @pytest.mark.asyncio + async def test_backward_compat_no_overrides(self): + """4. Neither configured → uses team.models as before.""" + team = LiteLLM_TeamTable(team_id="t1", models=["m1", "m2"]) + assert await can_team_access_model( + model="m1", team_object=team, llm_router=None + ) + + @pytest.mark.asyncio + async def test_backward_compat_empty_team_models_allows_all(self): + """12. team.models=[] with no overrides → allow all.""" + team = LiteLLM_TeamTable(team_id="t1", models=[]) + assert await can_team_access_model( + model="any-model", team_object=team, llm_router=None + ) + + @pytest.mark.asyncio + async def test_feature_flag_off_uses_team_models(self, monkeypatch): + """11. Feature flag off → ignores overrides, uses team.models.""" + litellm.team_model_overrides_enabled = False + monkeypatch.delenv("TEAM_MODEL_OVERRIDES", raising=False) + team = LiteLLM_TeamTable(team_id="t1", models=["m1"], default_models=["d1"]) + token = UserAPIKeyAuth(team_member_models=["mo1"]) + # Should use team.models=["m1"], not effective models + assert await can_team_access_model( + model="m1", team_object=team, llm_router=None, valid_token=token + ) + with pytest.raises(Exception): + await can_team_access_model( + model="d1", team_object=team, llm_router=None, valid_token=token + ) + + +# ── Key-generation enforcement tests ───────────────────────────────────────── + + +class TestKeyGenerationEnforcement: + """Tests 5, 6, 8: key-generation model validation against effective set.""" + + def _get_effective(self, team, token=None): + """Helper to compute effective models (same logic as key-gen).""" + return get_effective_team_models(team, token) + + def test_key_rejects_models_outside_effective_set(self): + """5. Key creation with models outside effective set → should be rejected.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "m3"], default_models=["m1"] + ) + token = UserAPIKeyAuth(team_member_models=["m2"]) + effective = self._get_effective(team, token) + + # Simulate key-gen validation: requested models must be subset of effective + requested = ["m3"] # not in effective set {m1, m2} + disallowed = set(requested) - set(effective) + assert disallowed == {"m3"}, "m3 should be disallowed" + + def test_key_defaults_to_effective_set(self): + """6. Key creation with no models → defaults to effective set.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "m3"], default_models=["m1"] + ) + token = UserAPIKeyAuth(team_member_models=["m2"]) + effective = self._get_effective(team, token) + + # When no models requested, key should get effective set + assert set(effective) == {"m1", "m2"} + + def test_all_team_models_restricted_to_effective_set(self): + """8. all-team-models key + overrides → restricted to effective set, not full team.models.""" + team = LiteLLM_TeamTable( + team_id="t1", models=["m1", "m2", "m3"], default_models=["m1"] + ) + token = UserAPIKeyAuth(team_member_models=["m2"]) + effective = self._get_effective(team, token) + + # all-team-models should resolve to effective set, not team.models + assert set(effective) == {"m1", "m2"} + assert "m3" not in effective # m3 is in team.models but not in effective + + +# ── Access group fallback test ─────────────────────────────────────────────── + + +class TestAccessGroupFallback: + @pytest.mark.asyncio + async def test_access_group_fallback_when_effective_models_deny(self): + """10. access_group_ids fallback still works when effective models check fails.""" + team = LiteLLM_TeamTable( + team_id="t1", + models=["m1", "m2"], + default_models=["m1"], + access_group_ids=["group-1"], + ) + # "m2" is NOT in effective set (only "m1" is default, no member overrides) + # But it should be accessible via access_group_ids fallback + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["m2", "m3"], + ): + result = await can_team_access_model( + model="m2", team_object=team, llm_router=None + ) + assert result is True + + @pytest.mark.asyncio + async def test_access_group_fallback_still_denies_unknown_model(self): + """10b. access_group_ids fallback does not grant access to models outside groups.""" + team = LiteLLM_TeamTable( + team_id="t1", + models=["m1", "m2"], + default_models=["m1"], + access_group_ids=["group-1"], + ) + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["m2"], # group only has m2 + ): + with pytest.raises(Exception): + await can_team_access_model( + model="unknown-model", team_object=team, llm_router=None + ) diff --git a/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py b/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py index a36dc7ff2e3..ab050b94448 100644 --- a/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py +++ b/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py @@ -54,13 +54,109 @@ async def test_upsert_disconnect(mock_tx, fake_user): user_api_key_dict=fake_user, ) - mock_tx.litellm_teammembership.update.assert_awaited_once_with( - where={"user_id_team_id": {"user_id": "user-1", "team_id": "team-1"}}, - data={"litellm_budget_table": {"disconnect": True}}, - ) + # All None + no existing budget → early return, no DB calls at all + mock_tx.litellm_teammembership.upsert.assert_not_called() + mock_tx.litellm_teammembership.update.assert_not_called() mock_tx.litellm_budgettable.update.assert_not_called() mock_tx.litellm_budgettable.create.assert_not_called() - mock_tx.litellm_teammembership.upsert.assert_not_called() + + +@pytest.mark.asyncio +async def test_upsert_disconnect_with_existing_budget(mock_tx, fake_user): + """When all params are None but a budget was linked, disconnect it.""" + await _upsert_budget_and_membership( + mock_tx, + team_id="team-1", + user_id="user-1", + max_budget=None, + existing_budget_id="budget-existing", + user_api_key_dict=fake_user, + ) + + mock_tx.litellm_teammembership.upsert.assert_awaited_once_with( + where={"user_id_team_id": {"user_id": "user-1", "team_id": "team-1"}}, + data={ + "create": {"user_id": "user-1", "team_id": "team-1"}, + "update": {"litellm_budget_table": {"disconnect": True}}, + }, + ) + + +@pytest.mark.asyncio +async def test_upsert_models_only_no_budget(mock_tx, fake_user): + """Setting models with no budget params → upsert with models only.""" + await _upsert_budget_and_membership( + mock_tx, + team_id="team-m", + user_id="user-m", + max_budget=None, + existing_budget_id=None, + user_api_key_dict=fake_user, + models=["gpt-4"], + ) + + mock_tx.litellm_budgettable.create.assert_not_called() + mock_tx.litellm_teammembership.upsert.assert_awaited_once_with( + where={"user_id_team_id": {"user_id": "user-m", "team_id": "team-m"}}, + data={ + "create": {"user_id": "user-m", "team_id": "team-m", "models": ["gpt-4"]}, + "update": {"models": ["gpt-4"]}, + }, + ) + + +@pytest.mark.asyncio +async def test_upsert_models_empty_list_clears(mock_tx, fake_user): + """Setting models=[] explicitly clears overrides.""" + await _upsert_budget_and_membership( + mock_tx, + team_id="team-c", + user_id="user-c", + max_budget=None, + existing_budget_id=None, + user_api_key_dict=fake_user, + models=[], + ) + + mock_tx.litellm_budgettable.create.assert_not_called() + mock_tx.litellm_teammembership.upsert.assert_awaited_once_with( + where={"user_id_team_id": {"user_id": "user-c", "team_id": "team-c"}}, + data={ + "create": {"user_id": "user-c", "team_id": "team-c", "models": []}, + "update": {"models": []}, + }, + ) + + +@pytest.mark.asyncio +async def test_upsert_models_plus_budget(mock_tx, fake_user): + """Setting models alongside a budget → both written in same upsert.""" + await _upsert_budget_and_membership( + mock_tx, + team_id="team-mb", + user_id="user-mb", + max_budget=50.0, + existing_budget_id=None, + user_api_key_dict=fake_user, + models=["gpt-4o"], + ) + + new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id + mock_tx.litellm_teammembership.upsert.assert_awaited_once_with( + where={"user_id_team_id": {"user_id": "user-mb", "team_id": "team-mb"}}, + data={ + "create": { + "user_id": "user-mb", + "team_id": "team-mb", + "litellm_budget_table": {"connect": {"budget_id": new_budget_id}}, + "models": ["gpt-4o"], + }, + "update": { + "litellm_budget_table": {"connect": {"budget_id": new_budget_id}}, + "models": ["gpt-4o"], + }, + }, + ) # TEST: existing budget id, creates new budget (current behavior) @@ -316,3 +412,25 @@ async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user): }, }, ) + + +@pytest.mark.asyncio +async def test_upsert_budget_change_preserves_models(mock_tx, fake_user): + """Updating budget with models=None should NOT touch existing models.""" + await _upsert_budget_and_membership( + mock_tx, + team_id="team-bp", + user_id="user-bp", + max_budget=100.0, + existing_budget_id=None, + user_api_key_dict=fake_user, + # models=None (default) — should not appear in upsert data + ) + + call_args = mock_tx.litellm_teammembership.upsert.call_args + create_data = call_args.kwargs["data"]["create"] + update_data = call_args.kwargs["data"]["update"] + + # models should NOT be in create or update data when models=None + assert "models" not in create_data, "models=None should not appear in create" + assert "models" not in update_data, "models=None should not appear in update" 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 366f659bdab..897b1640347 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1,15 +1,12 @@ -import asyncio import json import os import sys -from typing import Optional, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient -from litellm._uuid import uuid sys.path.insert( 0, os.path.abspath("../../../") @@ -17,7 +14,6 @@ sys.path.insert( from litellm.proxy._types import UserAPIKeyAuth # Import UserAPIKeyAuth from litellm.proxy._types import ( LiteLLM_OrganizationMembershipTable, - LiteLLM_OrganizationTable, LiteLLM_OrganizationTableWithMembers, LiteLLM_TeamTable, LiteLLM_UserTable, @@ -31,15 +27,12 @@ from litellm.proxy.management_endpoints.team_endpoints import ( user_api_key_auth, # Assuming this dependency is needed ) from litellm.proxy.management_endpoints.team_endpoints import ( - GetTeamMemberPermissionsResponse, - UpdateTeamMemberPermissionsRequest, _persist_deleted_team_records, _save_deleted_team_records, _transform_teams_to_deleted_records, _validate_and_populate_member_user_info, delete_team, list_available_teams, - router, team_member_add_duplication_check, team_member_delete, validate_team_org_change, @@ -447,10 +440,10 @@ async def test_new_team_with_object_permission(mock_db_client, mock_admin_auth): assert mock_team_create.call_count == 1 created_team_kwargs = mock_team_create.call_args.kwargs team_data = created_team_kwargs["data"] - + # Verify object_permission_id is in the team data assert team_data.get("object_permission_id") == "objperm123" - + # Verify object_permission dict is NOT in the team data assert "object_permission" not in team_data @@ -459,7 +452,7 @@ async def test_new_team_with_object_permission(mock_db_client, mock_admin_auth): async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_auth): """ Test that /team/new correctly handles mcp_tool_permissions in object_permission. - + This test verifies that: 1. mcp_tool_permissions is accepted in the object_permission field 2. The field is properly stored in the LiteLLM_ObjectPermissionTable @@ -497,9 +490,13 @@ async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_aut "object_permission_id": "objperm_team_mcp_456", } mock_db_client.db.litellm_teamtable = MagicMock() - mock_db_client.db.litellm_teamtable.create = AsyncMock(return_value=team_create_result) + mock_db_client.db.litellm_teamtable.create = AsyncMock( + return_value=team_create_result + ) mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0) - mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=team_create_result) + mock_db_client.db.litellm_teamtable.update = AsyncMock( + return_value=team_create_result + ) # Mock user table mock_db_client.db.litellm_usertable = MagicMock() @@ -532,6 +529,7 @@ async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_aut # Verify mcp_tool_permissions was stored import json + assert "mcp_tool_permissions" in created_permission_data # mcp_tool_permissions is stored as a JSON string assert json.loads(created_permission_data["mcp_tool_permissions"]) == { @@ -552,8 +550,6 @@ async def test_team_update_object_permissions_existing_permission(monkeypatch): """ from unittest.mock import AsyncMock, MagicMock - import pytest - from litellm.proxy._types import LiteLLM_ObjectPermissionBase, LiteLLM_TeamTable from litellm.proxy.management_endpoints.team_endpoints import ( handle_update_object_permission, @@ -624,8 +620,6 @@ async def test_team_update_object_permissions_no_existing_permission(monkeypatch """ from unittest.mock import AsyncMock, MagicMock - import pytest - from litellm.proxy._types import LiteLLM_ObjectPermissionBase, LiteLLM_TeamTable from litellm.proxy.management_endpoints.team_endpoints import ( handle_update_object_permission, @@ -684,8 +678,6 @@ async def test_team_update_object_permissions_missing_permission_record(monkeypa """ from unittest.mock import AsyncMock, MagicMock - import pytest - from litellm.proxy._types import LiteLLM_ObjectPermissionBase, LiteLLM_TeamTable from litellm.proxy.management_endpoints.team_endpoints import ( handle_update_object_permission, @@ -1074,6 +1066,7 @@ async def test_process_team_members_single_member(): litellm_proxy_admin_name="admin", team_id="test-team-123", default_team_budget_id="budget-123", + team_models=[], ) @@ -1213,7 +1206,6 @@ def test_add_new_models_to_team_with_existing_models(): """ Test add_new_models_to_team function with existing models """ - from litellm.proxy._types import SpecialModelNames from litellm.proxy.management_endpoints.team_endpoints import add_new_models_to_team team_obj = MagicMock(spec=LiteLLM_TeamTable) @@ -1263,7 +1255,6 @@ async def test_update_team_team_member_budget_not_passed_to_db(): ) as mock_cache_team, patch( "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" ) as mock_upsert_budget: - # Setup mock prisma client mock_existing_team = MagicMock() mock_existing_team.model_dump.return_value = { @@ -1288,7 +1279,13 @@ async def test_update_team_team_member_budget_not_passed_to_db(): # Mock budget upsert to return updated_kv without team_member_budget def mock_upsert_side_effect( - team_table, user_api_key_dict, updated_kv, team_member_budget=None, team_member_rpm_limit=None, team_member_tpm_limit=None, team_member_budget_duration=None + team_table, + user_api_key_dict, + updated_kv, + team_member_budget=None, + team_member_rpm_limit=None, + team_member_tpm_limit=None, + team_member_budget_duration=None, ): # Remove team_member_budget from updated_kv as the real function does result_kv = updated_kv.copy() @@ -1468,7 +1465,7 @@ async def test_create_team_member_budget_table(): with patch( "litellm.proxy.management_endpoints.budget_management_endpoints.new_budget", - new_callable=AsyncMock + new_callable=AsyncMock, ) as mock_new_budget: mock_new_budget.return_value = mock_budget_response @@ -1529,7 +1526,7 @@ async def test_create_team_member_budget_table_without_team_alias(): with patch( "litellm.proxy.management_endpoints.budget_management_endpoints.new_budget", - new_callable=AsyncMock + new_callable=AsyncMock, ) as mock_new_budget: mock_new_budget.return_value = mock_budget_response @@ -1579,7 +1576,7 @@ async def test_upsert_team_member_budget_table_existing_budget(): with patch( "litellm.proxy.management_endpoints.budget_management_endpoints.update_budget", - new_callable=AsyncMock + new_callable=AsyncMock, ) as mock_update_budget: mock_update_budget.return_value = mock_budget_response @@ -1641,7 +1638,7 @@ async def test_upsert_team_member_budget_table_no_existing_budget(): with patch( "litellm.proxy.management_endpoints.budget_management_endpoints.new_budget", - new_callable=AsyncMock + new_callable=AsyncMock, ) as mock_new_budget: mock_new_budget.return_value = mock_budget_response @@ -1691,7 +1688,6 @@ async def test_update_team_with_team_member_budget_duration(): ) as mock_cache_team, patch( "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" ) as mock_upsert_budget: - mock_existing_team = MagicMock() mock_existing_team.model_dump.return_value = { "team_id": "test_team_id", @@ -1714,7 +1710,13 @@ async def test_update_team_with_team_member_budget_duration(): ) def mock_upsert_side_effect( - team_table, user_api_key_dict, updated_kv, team_member_budget=None, team_member_rpm_limit=None, team_member_tpm_limit=None, team_member_budget_duration=None + team_table, + user_api_key_dict, + updated_kv, + team_member_budget=None, + team_member_rpm_limit=None, + team_member_tpm_limit=None, + team_member_budget_duration=None, ): result_kv = updated_kv.copy() result_kv.pop("team_member_budget", None) @@ -1757,7 +1759,6 @@ async def test_bulk_team_member_add_success(): from litellm.proxy._types import ( LiteLLM_TeamMembership, LiteLLM_UserTable, - TeamAddMemberResponse, ) from litellm.proxy.management_endpoints.team_endpoints import bulk_team_member_add @@ -1828,7 +1829,6 @@ async def test_bulk_team_member_add_success(): new_callable=AsyncMock, return_value=mock_team_response, ) as mock_team_member_add: - mock_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) result = await bulk_team_member_add( @@ -1917,7 +1917,7 @@ async def test_bulk_team_member_add_all_users_flag(): """ Test bulk_team_member_add with all_users flag set to True """ - from litellm.proxy._types import LiteLLM_UserTable, TeamAddMemberResponse + from litellm.proxy._types import TeamAddMemberResponse from litellm.proxy.management_endpoints.team_endpoints import bulk_team_member_add bulk_request = BulkTeamMemberAddRequest( @@ -1944,7 +1944,6 @@ async def test_bulk_team_member_add_all_users_flag(): new_callable=AsyncMock, return_value=mock_team_response, ) as mock_team_member_add: - # Mock the database find_many call mock_prisma.db.litellm_usertable.find_many = AsyncMock( return_value=mock_db_users @@ -1992,7 +1991,6 @@ async def test_bulk_team_member_add_failure_scenario(): new_callable=AsyncMock, side_effect=Exception("Database connection failed"), ) as mock_team_member_add: - mock_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) result = await bulk_team_member_add( @@ -2062,14 +2060,13 @@ async def test_list_team_v2_security_check_non_admin_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ - patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=None, - ): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client # Should raise HTTPException with 401 status @@ -2110,14 +2107,13 @@ async def test_list_team_v2_security_check_non_admin_user_other_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ - patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=None, - ): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client # Should raise HTTPException with 401 status @@ -2156,9 +2152,9 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"): # Mock prisma client and database operations mock_db = Mock() mock_prisma_client.db = mock_db @@ -2226,7 +2222,7 @@ async def test_list_team_v2_security_check_admin_user(): # Mock prisma client and database operations mock_db = Mock() mock_prisma_client.db = mock_db - + # Mock team lookup mock_teams = [ Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Team 1"}), @@ -2257,38 +2253,44 @@ async def test_list_team_v2_with_status_deleted(): Test that status="deleted" parameter correctly queries the deleted teams table. """ from unittest.mock import AsyncMock, Mock, patch - + from fastapi import Request - + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 - + # Mock request mock_request = Mock(spec=Request) - + # Mock admin user mock_user_api_key_dict_admin = UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user_123", ) - + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client: # Mock prisma client and database operations mock_db = Mock() mock_prisma_client.db = mock_db - + # Mock deleted teams - mock_deleted_team1 = Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Deleted Team 1"}) - mock_deleted_team2 = Mock(model_dump=lambda: {"team_id": "team_2", "team_alias": "Deleted Team 2"}) - + mock_deleted_team1 = Mock( + model_dump=lambda: {"team_id": "team_1", "team_alias": "Deleted Team 1"} + ) + mock_deleted_team2 = Mock( + model_dump=lambda: {"team_id": "team_2", "team_alias": "Deleted Team 2"} + ) + # Mock deleted teams table (should be called) - mock_db.litellm_deletedteamtable.find_many = AsyncMock(return_value=[mock_deleted_team1, mock_deleted_team2]) + mock_db.litellm_deletedteamtable.find_many = AsyncMock( + return_value=[mock_deleted_team1, mock_deleted_team2] + ) mock_db.litellm_deletedteamtable.count = AsyncMock(return_value=2) - + # Mock regular teams table (should NOT be called) mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[]) mock_db.litellm_teamtable.count = AsyncMock(return_value=0) - + # Should NOT raise an exception result = await list_team_v2( http_request=mock_request, @@ -2298,15 +2300,15 @@ async def test_list_team_v2_with_status_deleted(): page_size=10, status="deleted", # Test the status parameter ) - + # Verify that deleted table was queried mock_db.litellm_deletedteamtable.find_many.assert_called_once() mock_db.litellm_deletedteamtable.count.assert_called_once() - + # Verify that regular table was NOT queried mock_db.litellm_teamtable.find_many.assert_not_called() mock_db.litellm_teamtable.count.assert_not_called() - + # Should return results without error assert "teams" in result assert "total" in result @@ -2354,14 +2356,13 @@ async def test_list_team_v2_org_admin_sees_org_teams(): ], ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ - patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=mock_user, - ): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ): mock_db = Mock() mock_prisma.db = mock_db @@ -2438,14 +2439,13 @@ async def test_list_team_v2_org_admin_cannot_view_other_orgs(): ], ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ - patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=mock_user, - ): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ): mock_prisma.db = Mock() with pytest.raises(HTTPException) as exc_info: @@ -2464,9 +2464,10 @@ async def test_list_team_v2_org_admin_cannot_view_other_orgs(): ) assert exc_info.value.status_code == 403 - assert "only view teams within your organizations" in str( - exc_info.value.detail - ).lower() + assert ( + "only view teams within your organizations" + in str(exc_info.value.detail).lower() + ) @pytest.mark.asyncio @@ -2526,13 +2527,12 @@ async def test_list_team_v2_org_admin_with_user_id_returns_user_teams(): return mock_org_admin return mock_target_user - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ - patch("litellm.proxy.proxy_server.user_api_key_cache"), \ - patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ - patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - side_effect=mock_get_user_object, - ): + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( + "litellm.proxy.proxy_server.user_api_key_cache" + ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + side_effect=mock_get_user_object, + ): mock_db = Mock() mock_prisma.db = mock_db @@ -2589,7 +2589,7 @@ async def test_list_team_v2_with_invalid_status(): ) mock_prisma_client = Mock() - + # Mock prisma_client to be non-None with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): # Should raise HTTPException for invalid status @@ -2602,7 +2602,7 @@ async def test_list_team_v2_with_invalid_status(): page_size=10, status="invalid_status", # Invalid status value ) - + assert exc_info.value.status_code == 400 assert "Invalid status value" in str(exc_info.value.detail) assert "deleted" in str(exc_info.value.detail) @@ -2634,24 +2634,32 @@ async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_a } # Configure DB mocks used by team_member_delete - mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_team_row) + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) # User row to allow removal from user's teams list mock_user_row = MagicMock() mock_user_row.user_id = test_user_id mock_user_row.teams = [test_team_id] - mock_db_client.db.litellm_usertable.find_many = AsyncMock(return_value=[mock_user_row]) + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[mock_user_row] + ) mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) # Membership deletion should be called mock_db_client.db.litellm_teammembership = MagicMock() - mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(return_value=MagicMock()) + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) # Verification token deletion should be called mock_db_client.db.litellm_verificationtoken = MagicMock() mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) - mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(return_value=MagicMock()) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) # Execute await team_member_delete( @@ -2663,10 +2671,12 @@ async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_a mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_with( where={"team_id": test_team_id, "user_id": test_user_id} ) - + @pytest.mark.asyncio -async def test_team_member_delete_cleans_verification_tokens(mock_db_client, mock_admin_auth): +async def test_team_member_delete_cleans_verification_tokens( + mock_db_client, mock_admin_auth +): from litellm.proxy._types import TeamMemberDeleteRequest from litellm.proxy.management_endpoints.team_endpoints import team_member_delete @@ -2685,21 +2695,29 @@ async def test_team_member_delete_cleans_verification_tokens(mock_db_client, moc "spend": 0.0, } - mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_team_row) + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) mock_user_row = MagicMock() mock_user_row.user_id = test_user_id mock_user_row.teams = [test_team_id] - mock_db_client.db.litellm_usertable.find_many = AsyncMock(return_value=[mock_user_row]) + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[mock_user_row] + ) mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) mock_db_client.db.litellm_teammembership = MagicMock() - mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(return_value=MagicMock()) + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) mock_db_client.db.litellm_verificationtoken = MagicMock() mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) - mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(return_value=MagicMock()) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) await team_member_delete( data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id), @@ -2718,7 +2736,7 @@ async def test_team_member_delete_cleans_verification_tokens(mock_db_client, moc async def test_new_team_max_budget_exceeds_user_max_budget(): """ Test that /team/new raises ProxyException when max_budget exceeds user's end_user_max_budget. - + This validates the budget enforcement logic where non-admin users cannot create teams with budgets higher than their personal maximum budget limit. """ @@ -2755,15 +2773,16 @@ async def test_new_team_max_budget_exceeds_user_max_budget(): mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False mock_prisma.get_data = AsyncMock(return_value=None) - + # Mock user cache to return a user object with max_budget=100.0 from litellm.proxy._types import LiteLLM_UserTable + mock_user_obj = LiteLLM_UserTable( user_id="non-admin-user-123", max_budget=100.0, ) mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - + # Should raise ProxyException (HTTPException gets converted by handle_exception_on_proxy) with pytest.raises(ProxyException) as exc_info: await new_team( @@ -2774,9 +2793,11 @@ async def test_new_team_max_budget_exceeds_user_max_budget(): # Verify exception details # ProxyException stores status_code in 'code' attribute - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "max budget higher than user max" in str(exc_info.value.message) - assert "100.0" in str(exc_info.value.message) # User's user_max_budget should be mentioned + assert "100.0" in str( + exc_info.value.message + ) # User's user_max_budget should be mentioned assert LitellmUserRoles.INTERNAL_USER.value in str(exc_info.value.message) @@ -2784,7 +2805,7 @@ async def test_new_team_max_budget_exceeds_user_max_budget(): async def test_new_team_max_budget_within_user_limit(): """ Test that /team/new succeeds when max_budget is within user's user_max_budget. - + This ensures that users can create teams with budgets at or below their personal limit. """ from fastapi import Request @@ -2817,22 +2838,22 @@ async def test_new_team_max_budget_within_user_limit(): ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False mock_prisma.jsonify_team_object = lambda db_data: db_data mock_prisma.get_data = AsyncMock(return_value=None) mock_prisma.update_data = AsyncMock() - + # Mock user cache to return a user object with max_budget=100.0 from litellm.proxy._types import LiteLLM_UserTable + mock_user_obj = LiteLLM_UserTable( user_id="non-admin-user-456", max_budget=100.0, ) mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - + # Mock team creation mock_created_team = MagicMock() mock_created_team.team_id = "team-within-budget-789" @@ -2846,21 +2867,30 @@ async def test_new_team_max_budget_within_user_limit(): "max_budget": 50.0, "members_with_roles": [], } - mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) - + mock_prisma.db.litellm_teamtable.create = AsyncMock( + return_value=mock_created_team + ) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_created_team + ) + # Mock model table mock_prisma.db.litellm_modeltable = MagicMock() - mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) - + mock_prisma.db.litellm_modeltable.create = AsyncMock( + return_value=MagicMock(id="model123") + ) + # Mock user table operations for adding the creator as a member mock_user = MagicMock() mock_user.user_id = "non-admin-user-456" - mock_user.model_dump.return_value = {"user_id": "non-admin-user-456", "teams": ["team-within-budget-789"]} + mock_user.model_dump.return_value = { + "user_id": "non-admin-user-456", + "teams": ["team-within-budget-789"], + } mock_prisma.db.litellm_usertable = MagicMock() mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user) mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user) - + # Mock team membership table mock_membership = MagicMock() mock_membership.model_dump.return_value = { @@ -2869,7 +2899,9 @@ async def test_new_team_max_budget_within_user_limit(): "budget_id": None, } mock_prisma.db.litellm_teammembership = MagicMock() - mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership) + mock_prisma.db.litellm_teammembership.create = AsyncMock( + return_value=mock_membership + ) # Should NOT raise an exception result = await new_team( @@ -2902,7 +2934,6 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, LiteLLM_UserTable, NewTeamRequest, UserAPIKeyAuth, @@ -2937,7 +2968,6 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit(): ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object" ) as mock_get_org: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -2975,17 +3005,26 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit(): "organization_id": "test-org-123", "members_with_roles": [], } - mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_teamtable.create = AsyncMock( + return_value=mock_created_team + ) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_created_team + ) # Mock model table mock_prisma.db.litellm_modeltable = MagicMock() - mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) + mock_prisma.db.litellm_modeltable.create = AsyncMock( + return_value=MagicMock(id="model123") + ) # Mock user table operations mock_user = MagicMock() mock_user.user_id = "org-admin-user-123" - mock_user.model_dump.return_value = {"user_id": "org-admin-user-123", "teams": ["team-org-scoped-789"]} + mock_user.model_dump.return_value = { + "user_id": "org-admin-user-123", + "teams": ["team-org-scoped-789"], + } mock_prisma.db.litellm_usertable = MagicMock() mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user) mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user) @@ -2998,7 +3037,9 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit(): "budget_id": None, } mock_prisma.db.litellm_teammembership = MagicMock() - mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership) + mock_prisma.db.litellm_teammembership.create = AsyncMock( + return_value=mock_membership + ) # Should NOT raise an exception - the fix should bypass user budget validation for org-scoped teams result = await new_team( @@ -3032,7 +3073,6 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, LiteLLM_UserTable, NewTeamRequest, UserAPIKeyAuth, @@ -3050,7 +3090,9 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): # Create team request with models that are within org's allowed models but not user's team_request = NewTeamRequest( team_alias="org-scoped-models-team", - models=["gpt-4"], # Within org's allowed models, but not in user's personal models + models=[ + "gpt-4" + ], # Within org's allowed models, but not in user's personal models organization_id="test-org-456", # This makes it an org-scoped team ) @@ -3067,7 +3109,6 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object" ) as mock_get_org: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3107,17 +3148,26 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): "models": ["gpt-4"], "members_with_roles": [], } - mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_teamtable.create = AsyncMock( + return_value=mock_created_team + ) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_created_team + ) # Mock model table mock_prisma.db.litellm_modeltable = MagicMock() - mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) + mock_prisma.db.litellm_modeltable.create = AsyncMock( + return_value=MagicMock(id="model123") + ) # Mock user table operations mock_user = MagicMock() mock_user.user_id = "org-admin-user-456" - mock_user.model_dump.return_value = {"user_id": "org-admin-user-456", "teams": ["team-org-scoped-models-789"]} + mock_user.model_dump.return_value = { + "user_id": "org-admin-user-456", + "teams": ["team-org-scoped-models-789"], + } mock_prisma.db.litellm_usertable = MagicMock() mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user) mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user) @@ -3130,7 +3180,9 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): "budget_id": None, } mock_prisma.db.litellm_teammembership = MagicMock() - mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership) + mock_prisma.db.litellm_teammembership.create = AsyncMock( + return_value=mock_membership + ) # Should NOT raise an exception - the fix should bypass user model validation for org-scoped teams result = await new_team( @@ -3201,7 +3253,7 @@ async def test_new_team_standalone_validates_against_user_models(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "Model not in allowed user models" in str(exc_info.value.message) assert "no-default-models" in str(exc_info.value.message) @@ -3277,9 +3329,11 @@ async def test_new_team_standalone_validates_against_user_budget(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "max budget higher than user max" in str(exc_info.value.message) - assert "3.0" in str(exc_info.value.message) # User's max_budget should be mentioned + assert "3.0" in str( + exc_info.value.message + ) # User's max_budget should be mentioned @pytest.mark.asyncio @@ -3296,7 +3350,6 @@ async def test_new_team_org_scoped_budget_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, NewTeamRequest, ProxyException, UserAPIKeyAuth, @@ -3330,7 +3383,6 @@ async def test_new_team_org_scoped_budget_exceeds_org_limit(): ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object" ) as mock_get_org: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3355,8 +3407,11 @@ async def test_new_team_org_scoped_budget_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' - assert "exceeds organization" in str(exc_info.value.message).lower() or "organization" in str(exc_info.value.message).lower() + assert exc_info.value.code == "400" + assert ( + "exceeds organization" in str(exc_info.value.message).lower() + or "organization" in str(exc_info.value.message).lower() + ) @pytest.mark.asyncio @@ -3372,8 +3427,6 @@ async def test_new_team_org_scoped_models_not_in_org_models(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, NewTeamRequest, ProxyException, UserAPIKeyAuth, @@ -3407,7 +3460,6 @@ async def test_new_team_org_scoped_models_not_in_org_models(): ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object" ) as mock_get_org: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3429,8 +3481,11 @@ async def test_new_team_org_scoped_models_not_in_org_models(): ) # Verify exception details - assert exc_info.value.code == '400' - assert "claude-3-opus" in str(exc_info.value.message) or "organization" in str(exc_info.value.message).lower() + assert exc_info.value.code == "400" + assert ( + "claude-3-opus" in str(exc_info.value.message) + or "organization" in str(exc_info.value.message).lower() + ) @pytest.mark.asyncio @@ -3476,7 +3531,6 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit: - # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-123" @@ -3487,7 +3541,9 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): "organization_id": None, "max_budget": 30.0, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Mock user cache to return user with restrictive budget mock_user_obj = LiteLLM_UserTable( @@ -3505,7 +3561,7 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "budget" in str(exc_info.value.message).lower() @@ -3524,7 +3580,6 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, @@ -3563,9 +3618,8 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ) as mock_get_org: - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-456" @@ -3576,7 +3630,9 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): "organization_id": "test-org-update", "max_budget": 80.0, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because new budget exceeds org's max_budget with pytest.raises(ProxyException) as exc_info: @@ -3587,8 +3643,11 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' - assert "organization" in str(exc_info.value.message).lower() or "budget" in str(exc_info.value.message).lower() + assert exc_info.value.code == "400" + assert ( + "organization" in str(exc_info.value.message).lower() + or "budget" in str(exc_info.value.message).lower() + ) @pytest.mark.asyncio @@ -3629,7 +3688,6 @@ async def test_update_team_standalone_models_exceeds_user_limit(): ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit: - # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-models-123" @@ -3640,7 +3698,9 @@ async def test_update_team_standalone_models_exceeds_user_limit(): "organization_id": None, "models": ["gpt-3.5-turbo"], } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because model not in user's allowed models with pytest.raises(ProxyException) as exc_info: @@ -3651,7 +3711,7 @@ async def test_update_team_standalone_models_exceeds_user_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "model" in str(exc_info.value.message).lower() @@ -3671,7 +3731,6 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, LiteLLM_UserTable, UpdateTeamRequest, UserAPIKeyAuth, @@ -3710,9 +3769,8 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ) as mock_get_org: - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-budget-123" @@ -3724,7 +3782,9 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): "organization_id": "test-org-update-budget", "max_budget": 30.0, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) mock_prisma.jsonify_team_object = lambda db_data: db_data # Mock user cache to return user with restrictive budget @@ -3733,7 +3793,9 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): max_budget=3.0, # Restrictive personal budget ) mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - mock_cache.async_set_cache = AsyncMock() # Mock cache set for _cache_team_object + mock_cache.async_set_cache = ( + AsyncMock() + ) # Mock cache set for _cache_team_object # Mock team update mock_updated_team = MagicMock() @@ -3746,7 +3808,9 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): "organization_id": "test-org-update-budget", "max_budget": 50.0, } - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) # Should NOT raise an exception - bypass user budget validation for org-scoped teams result = await update_team( @@ -3775,7 +3839,6 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, UpdateTeamRequest, UserAPIKeyAuth, ) @@ -3810,9 +3873,8 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ) as mock_get_org: - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-models-123" @@ -3824,9 +3886,13 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(): "organization_id": "test-org-update-models", "models": ["gpt-3.5-turbo"], } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) mock_prisma.jsonify_team_object = lambda db_data: db_data - mock_cache.async_set_cache = AsyncMock() # Mock cache set for _cache_team_object + mock_cache.async_set_cache = ( + AsyncMock() + ) # Mock cache set for _cache_team_object # Mock team update mock_updated_team = MagicMock() @@ -3839,7 +3905,9 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(): "organization_id": "test-org-update-models", "models": ["gpt-4"], } - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) # Should NOT raise an exception - bypass user models validation for org-scoped teams result = await update_team( @@ -3867,7 +3935,6 @@ async def test_update_team_org_scoped_models_not_in_org_models(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, @@ -3903,9 +3970,8 @@ async def test_update_team_org_scoped_models_not_in_org_models(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ) as mock_get_org: - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-models-fail-123" @@ -3916,7 +3982,9 @@ async def test_update_team_org_scoped_models_not_in_org_models(): "organization_id": "test-org-update-models-fail", "models": ["gpt-4"], } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because claude-3-opus is not in org's allowed models with pytest.raises(ProxyException) as exc_info: @@ -3927,8 +3995,11 @@ async def test_update_team_org_scoped_models_not_in_org_models(): ) # Verify exception details - assert exc_info.value.code == '400' - assert "claude-3-opus" in str(exc_info.value.message) or "organization" in str(exc_info.value.message).lower() + assert exc_info.value.code == "400" + assert ( + "claude-3-opus" in str(exc_info.value.message) + or "organization" in str(exc_info.value.message).lower() + ) @pytest.mark.asyncio @@ -3945,7 +4016,6 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, SpecialModelNames, UpdateTeamRequest, UserAPIKeyAuth, @@ -3982,9 +4052,8 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ) as mock_get_org: - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-all-proxy-models-123" @@ -3996,22 +4065,36 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): "organization_id": "test-org-all-proxy-models", "models": ["gpt-4"], } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) mock_prisma.jsonify_team_object = lambda db_data: db_data - mock_cache.async_set_cache = AsyncMock() # Mock cache set for _cache_team_object + mock_cache.async_set_cache = ( + AsyncMock() + ) # Mock cache set for _cache_team_object # Mock team update mock_updated_team = MagicMock() mock_updated_team.team_id = "org-team-all-proxy-models-123" mock_updated_team.organization_id = "test-org-all-proxy-models" - mock_updated_team.models = ["rerank-english-v3.0", "text-embedding-3-small", "gpt-4o-mini-test"] + mock_updated_team.models = [ + "rerank-english-v3.0", + "text-embedding-3-small", + "gpt-4o-mini-test", + ] mock_updated_team.litellm_model_table = None mock_updated_team.model_dump.return_value = { "team_id": "org-team-all-proxy-models-123", "organization_id": "test-org-all-proxy-models", - "models": ["rerank-english-v3.0", "text-embedding-3-small", "gpt-4o-mini-test"], + "models": [ + "rerank-english-v3.0", + "text-embedding-3-small", + "gpt-4o-mini-test", + ], } - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) # Should NOT raise an exception - 'all-proxy-models' allows all models result = await update_team( @@ -4022,7 +4105,11 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): # Verify the team was updated successfully with the new models assert result is not None - assert result["data"].models == ["rerank-english-v3.0", "text-embedding-3-small", "gpt-4o-mini-test"] + assert result["data"].models == [ + "rerank-english-v3.0", + "text-embedding-3-small", + "gpt-4o-mini-test", + ] @pytest.mark.asyncio @@ -4061,7 +4148,6 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): ) as mock_cache, patch( "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" ): - # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-tpm-test-123" @@ -4072,7 +4158,9 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): "organization_id": None, "tpm_limit": 500, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because new TPM exceeds user's limit with pytest.raises(ProxyException) as exc_info: @@ -4083,7 +4171,7 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "tpm" in str(exc_info.value.message).lower() @@ -4123,7 +4211,6 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): ) as mock_cache, patch( "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" ): - # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-rpm-test-123" @@ -4134,7 +4221,9 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): "organization_id": None, "rpm_limit": 50, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because new RPM exceeds user's limit with pytest.raises(ProxyException) as exc_info: @@ -4145,7 +4234,7 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "rpm" in str(exc_info.value.message).lower() @@ -4163,7 +4252,6 @@ async def test_new_team_org_scoped_tpm_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, NewTeamRequest, ProxyException, UserAPIKeyAuth, @@ -4206,7 +4294,7 @@ async def test_new_team_org_scoped_tpm_exceeds_org_limit(): "litellm.proxy.proxy_server._license_check" ) as mock_license, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4221,7 +4309,7 @@ async def test_new_team_org_scoped_tpm_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "tpm" in str(exc_info.value.message).lower() @@ -4239,7 +4327,6 @@ async def test_new_team_org_scoped_rpm_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, NewTeamRequest, ProxyException, UserAPIKeyAuth, @@ -4282,7 +4369,7 @@ async def test_new_team_org_scoped_rpm_exceeds_org_limit(): "litellm.proxy.proxy_server._license_check" ) as mock_license, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4297,7 +4384,7 @@ async def test_new_team_org_scoped_rpm_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "rpm" in str(exc_info.value.message).lower() @@ -4316,7 +4403,6 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, LiteLLM_TeamTable, NewTeamRequest, UserAPIKeyAuth, @@ -4329,7 +4415,7 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): user_id="org-admin-bypass-test", models=[], tpm_limit=1000, # Restrictive user TPM limit - rpm_limit=100, # Restrictive user RPM limit + rpm_limit=100, # Restrictive user RPM limit ) # Create team request exceeding user limits but within org limits @@ -4337,7 +4423,7 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): team_alias="org-bypass-test-team", organization_id="test-org-bypass", tpm_limit=10000, # Exceeds user's 1000 but within org's 50000 - rpm_limit=1000, # Exceeds user's 100 but within org's 5000 + rpm_limit=1000, # Exceeds user's 100 but within org's 5000 ) dummy_request = MagicMock(spec=Request) @@ -4345,7 +4431,7 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): # Mock organization with generous limits mock_budget_table = MagicMock(spec=LiteLLM_BudgetTable) mock_budget_table.tpm_limit = 50000 # Generous org TPM limit - mock_budget_table.rpm_limit = 5000 # Generous org RPM limit + mock_budget_table.rpm_limit = 5000 # Generous org RPM limit mock_budget_table.max_budget = None mock_org = MagicMock(spec=LiteLLM_OrganizationTable) @@ -4363,10 +4449,10 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ), patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ), patch( "litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team", - new=AsyncMock() + new=AsyncMock(), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4388,8 +4474,12 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): "metadata": None, "members_with_roles": [], } - mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_teamtable.create = AsyncMock( + return_value=mock_created_team + ) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_created_team + ) mock_prisma.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data) # Should succeed - bypasses user limits since org-scoped @@ -4417,7 +4507,6 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, @@ -4457,9 +4546,8 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit(): "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" ), patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ): - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-tpm-123" @@ -4470,7 +4558,9 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit(): "organization_id": "test-org-update-tpm", "tpm_limit": 5000, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because TPM exceeds org limit with pytest.raises(ProxyException) as exc_info: @@ -4481,7 +4571,7 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "tpm" in str(exc_info.value.message).lower() @@ -4499,7 +4589,6 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, @@ -4539,9 +4628,8 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit(): "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" ), patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ): - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-rpm-123" @@ -4552,7 +4640,9 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit(): "organization_id": "test-org-update-rpm", "rpm_limit": 500, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Should raise ProxyException because RPM exceeds org limit with pytest.raises(ProxyException) as exc_info: @@ -4563,7 +4653,7 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit(): ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" assert "rpm" in str(exc_info.value.message).lower() @@ -4582,7 +4672,6 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): from litellm.proxy._types import ( LiteLLM_BudgetTable, - LiteLLM_OrganizationTable, LiteLLM_TeamTable, UpdateTeamRequest, UserAPIKeyAuth, @@ -4595,14 +4684,14 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): user_id="org-admin-update-bypass-test", models=[], tpm_limit=1000, # Restrictive user TPM limit - rpm_limit=100, # Restrictive user RPM limit + rpm_limit=100, # Restrictive user RPM limit ) # Create update request exceeding user limits but within org limits update_request = UpdateTeamRequest( team_id="org-team-update-bypass-123", tpm_limit=10000, # Exceeds user's 1000 but within org's 50000 - rpm_limit=1000, # Exceeds user's 100 but within org's 5000 + rpm_limit=1000, # Exceeds user's 100 but within org's 5000 ) dummy_request = MagicMock(spec=Request) @@ -4610,7 +4699,7 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): # Mock organization with generous limits mock_budget_table = MagicMock(spec=LiteLLM_BudgetTable) mock_budget_table.tpm_limit = 50000 # Generous org TPM limit - mock_budget_table.rpm_limit = 5000 # Generous org RPM limit + mock_budget_table.rpm_limit = 5000 # Generous org RPM limit mock_budget_table.max_budget = None mock_org = MagicMock(spec=LiteLLM_OrganizationTable) @@ -4626,9 +4715,8 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): "litellm.proxy.proxy_server.proxy_logging_obj" ) as mock_logging, patch( "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org) + new=AsyncMock(return_value=mock_org), ): - # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-bypass-123" @@ -4642,7 +4730,9 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): "tpm_limit": 5000, "rpm_limit": 500, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) mock_cache.async_set_cache = AsyncMock() # Mock team update @@ -4655,7 +4745,9 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): "tpm_limit": 10000, "rpm_limit": 1000, } - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) mock_prisma.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data) # Should succeed - bypasses user limits since org-scoped @@ -4678,7 +4770,6 @@ async def test_update_team_guardrails_with_org_id(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_OrganizationTable, LiteLLM_TeamTable, UpdateTeamRequest, UserAPIKeyAuth, @@ -4703,6 +4794,7 @@ async def test_update_team_guardrails_with_org_id(): # Mock organization with all required fields including teams (the fix) from datetime import datetime + mock_org = MagicMock(spec=LiteLLM_OrganizationTable) mock_org.organization_id = "test-org-guardrails" mock_org.models = ["gpt-4", "gpt-3.5-turbo"] @@ -4734,7 +4826,8 @@ async def test_update_team_guardrails_with_org_id(): ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ), patch( - "litellm.proxy.proxy_server.premium_user", True # Required for guardrails feature + "litellm.proxy.proxy_server.premium_user", + True, # Required for guardrails feature ): # Mock existing team - must have compatible models with organization mock_existing_team = MagicMock() @@ -4770,7 +4863,9 @@ async def test_update_team_guardrails_with_org_id(): mock_updated_team = MagicMock(spec=LiteLLM_TeamTable) mock_updated_team.team_id = "team-guardrails-123" mock_updated_team.organization_id = "test-org-guardrails" - mock_updated_team.metadata = {"guardrails": ["aporia-pre-call", "aporia-post-call"]} + mock_updated_team.metadata = { + "guardrails": ["aporia-pre-call", "aporia-post-call"] + } mock_updated_team.litellm_model_table = None mock_updated_team.model_dump.return_value = { "team_id": "team-guardrails-123", @@ -4795,16 +4890,23 @@ async def test_update_team_guardrails_with_org_id(): # Verify the team was updated successfully with guardrails assert result is not None assert result["data"].organization_id == "test-org-guardrails" - assert result["data"].metadata["guardrails"] == ["aporia-pre-call", "aporia-post-call"] + assert result["data"].metadata["guardrails"] == [ + "aporia-pre-call", + "aporia-post-call", + ] # Verify that organization fetch was called with proper include clause # The function is called twice: once by fetch_and_validate_organization (with include) # and once by get_org_object (without include). We verify the first call has 'teams'. assert mock_prisma.db.litellm_organizationtable.find_unique.call_count >= 1 - + # Get the first call (from fetch_and_validate_organization) - first_call_kwargs = mock_prisma.db.litellm_organizationtable.find_unique.call_args_list[0].kwargs - + first_call_kwargs = ( + mock_prisma.db.litellm_organizationtable.find_unique.call_args_list[ + 0 + ].kwargs + ) + # Verify that 'teams' is included in the fetch assert "include" in first_call_kwargs assert "teams" in first_call_kwargs["include"] @@ -4812,8 +4914,6 @@ async def test_update_team_guardrails_with_org_id(): def test_transform_teams_to_deleted_records(): - from datetime import datetime, timezone - user_api_key_dict = UserAPIKeyAuth( user_id="user-123", api_key="sk-test", @@ -4854,7 +4954,9 @@ def test_transform_teams_to_deleted_records(): assert all("litellm_changed_by" in record for record in records) assert all(record["deleted_by"] == "user-123" for record in records) # UserAPIKeyAuth hashes the api_key, so we check against the hashed value - assert all(record["deleted_by_api_key"] == user_api_key_dict.api_key for record in records) + assert all( + record["deleted_by_api_key"] == user_api_key_dict.api_key for record in records + ) assert all(record["litellm_changed_by"] == "admin-user" for record in records) record1 = records[0] @@ -5153,16 +5255,18 @@ async def test_team_member_delete_persists_deleted_keys(monkeypatch): assert all(record["team_id"] == "team-1" for record in records) assert all(record["user_id"] == "user-123" for record in records) mock_delete_keys.assert_called_once() + + @pytest.mark.asyncio async def test_new_team_negative_max_budget(): """ Test that NewTeamRequest model allows negative max_budget values. Validation is done at API level, not model level. - + This prevents GET requests from breaking when they receive data with negative budgets. """ from litellm.proxy._types import NewTeamRequest - + # Should not raise any errors at model level request = NewTeamRequest(team_alias="test-team", max_budget=-7.0) assert request.max_budget == -7.0 @@ -5175,7 +5279,7 @@ async def test_new_team_negative_team_member_budget(): Validation is done at API level, not model level. """ from litellm.proxy._types import NewTeamRequest - + # Should not raise any errors at model level request = NewTeamRequest(team_alias="test-team", team_member_budget=-10.0) assert request.team_member_budget == -10.0 @@ -5188,7 +5292,7 @@ async def test_update_team_negative_max_budget(): Validation is done at API level, not model level. """ from litellm.proxy._types import UpdateTeamRequest - + # Should not raise any errors at model level request = UpdateTeamRequest(team_id="test-team-id", max_budget=-5.0) assert request.max_budget == -5.0 @@ -5201,7 +5305,7 @@ async def test_update_team_negative_team_member_budget(): Validation is done at API level, not model level. """ from litellm.proxy._types import UpdateTeamRequest - + # Should not raise any errors at model level request = UpdateTeamRequest(team_id="test-team-id", team_member_budget=-15.0) assert request.team_member_budget == -15.0 @@ -5216,18 +5320,37 @@ async def test_update_team_negative_team_member_budget(): # Test 2: Soft budget with higher max budget, success with both set (50.0, 100.0, True, 50.0, 100.0, None), # Test 3: Soft budget with lower max budget, fail - (100.0, 50.0, False, None, None, "soft_budget (100.0) must be strictly lower than max_budget (50.0)"), + ( + 100.0, + 50.0, + False, + None, + None, + "soft_budget (100.0) must be strictly lower than max_budget (50.0)", + ), # Test 4: Soft budget equal to max budget, fail - (100.0, 100.0, False, None, None, "soft_budget (100.0) must be strictly lower than max_budget (100.0)"), + ( + 100.0, + 100.0, + False, + None, + None, + "soft_budget (100.0) must be strictly lower than max_budget (100.0)", + ), ], ) @pytest.mark.asyncio async def test_new_team_soft_budget_validation( - soft_budget, max_budget, should_succeed, expected_soft_budget, expected_max_budget, error_message + soft_budget, + max_budget, + should_succeed, + expected_soft_budget, + expected_max_budget, + error_message, ): """ Test soft_budget validation in /team/new endpoint. - + Covers: - Soft budget only - success + soft budget set - Soft budget with higher max budget, success with both set @@ -5263,22 +5386,22 @@ async def test_new_team_soft_budget_validation( ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit: - # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False mock_prisma.jsonify_team_object = lambda db_data: db_data mock_prisma.get_data = AsyncMock(return_value=None) mock_prisma.update_data = AsyncMock() - + # Mock user cache from litellm.proxy._types import LiteLLM_UserTable + mock_user_obj = LiteLLM_UserTable( user_id="admin-user", max_budget=None, # Admin has no budget limit ) mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - + # Mock team creation mock_created_team = MagicMock() mock_created_team.team_id = "test-team-123" @@ -5294,21 +5417,30 @@ async def test_new_team_soft_budget_validation( "max_budget": expected_max_budget, "members_with_roles": [], } - mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) - + mock_prisma.db.litellm_teamtable.create = AsyncMock( + return_value=mock_created_team + ) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_created_team + ) + # Mock model table mock_prisma.db.litellm_modeltable = MagicMock() - mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) - + mock_prisma.db.litellm_modeltable.create = AsyncMock( + return_value=MagicMock(id="model123") + ) + # Mock user table operations mock_user = MagicMock() mock_user.user_id = "admin-user" - mock_user.model_dump.return_value = {"user_id": "admin-user", "teams": ["test-team-123"]} + mock_user.model_dump.return_value = { + "user_id": "admin-user", + "teams": ["test-team-123"], + } mock_prisma.db.litellm_usertable = MagicMock() mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user) mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user) - + # Mock team membership table mock_membership = MagicMock() mock_membership.model_dump.return_value = { @@ -5317,7 +5449,9 @@ async def test_new_team_soft_budget_validation( "budget_id": None, } mock_prisma.db.litellm_teammembership = MagicMock() - mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership) + mock_prisma.db.litellm_teammembership.create = AsyncMock( + return_value=mock_membership + ) if should_succeed: # Should NOT raise an exception @@ -5344,7 +5478,7 @@ async def test_new_team_soft_budget_validation( ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" if error_message: assert error_message in str(exc_info.value.message) @@ -5358,25 +5492,58 @@ async def test_new_team_soft_budget_validation( # Test 2: Soft budget with max budget - success if soft budget is strictly lower than max budget (None, None, 50.0, 100.0, True, 50.0, 100.0, None), # Test 3: Soft budget with max budget - fail if soft budget >= max budget - (None, None, 100.0, 50.0, False, None, None, "soft_budget (100.0) must be strictly lower than max_budget (50.0)"), + ( + None, + None, + 100.0, + 50.0, + False, + None, + None, + "soft_budget (100.0) must be strictly lower than max_budget (50.0)", + ), # Test 4: Only max budget with existing soft_budget, success with max_budget strictly greater (50.0, None, None, 100.0, True, 50.0, 100.0, None), # Test 5: Only max budget with existing soft_budget, fail if max_budget <= soft_budget - (50.0, None, None, 50.0, False, None, None, "max_budget (50.0) must be strictly greater than soft_budget (50.0)"), + ( + 50.0, + None, + None, + 50.0, + False, + None, + None, + "max_budget (50.0) must be strictly greater than soft_budget (50.0)", + ), # Test 6: Update both soft_budget and max_budget - success if soft < max (30.0, 100.0, 40.0, 80.0, True, 40.0, 80.0, None), # Test 7: Update both soft_budget and max_budget - fail if soft >= max - (30.0, 100.0, 80.0, 40.0, False, None, None, "soft_budget (80.0) must be strictly lower than max_budget (40.0)"), + ( + 30.0, + 100.0, + 80.0, + 40.0, + False, + None, + None, + "soft_budget (80.0) must be strictly lower than max_budget (40.0)", + ), ], ) @pytest.mark.asyncio async def test_update_team_soft_budget_validation( - existing_soft_budget, existing_max_budget, update_soft_budget, update_max_budget, - should_succeed, expected_soft_budget, expected_max_budget, error_message + existing_soft_budget, + existing_max_budget, + update_soft_budget, + update_max_budget, + should_succeed, + expected_soft_budget, + expected_max_budget, + error_message, ): """ Test soft_budget validation in /team/update endpoint. - + Covers: - Soft budget only (no previous max_budget) - success with soft budget set - Soft budget with max budget - success if soft budget is strictly lower than max budget, fail otherwise @@ -5415,7 +5582,6 @@ async def test_update_team_soft_budget_validation( ), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() ) as mock_audit: - # Mock existing team with existing budgets mock_existing_team = MagicMock() mock_existing_team.team_id = "test-team-123" @@ -5428,7 +5594,9 @@ async def test_update_team_soft_budget_validation( "soft_budget": existing_soft_budget, "max_budget": existing_max_budget, } - mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) # Mock user cache mock_user_obj = LiteLLM_UserTable( @@ -5438,9 +5606,15 @@ async def test_update_team_soft_budget_validation( mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) # Mock updated team - preserve existing values if not being updated - final_soft_budget = update_soft_budget if update_soft_budget is not None else existing_soft_budget - final_max_budget = update_max_budget if update_max_budget is not None else existing_max_budget - + final_soft_budget = ( + update_soft_budget + if update_soft_budget is not None + else existing_soft_budget + ) + final_max_budget = ( + update_max_budget if update_max_budget is not None else existing_max_budget + ) + mock_updated_team = MagicMock() mock_updated_team.team_id = "test-team-123" mock_updated_team.organization_id = None @@ -5452,9 +5626,13 @@ async def test_update_team_soft_budget_validation( "soft_budget": final_soft_budget, "max_budget": final_max_budget, } - mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) mock_prisma.jsonify_team_object = lambda db_data: db_data - mock_cache.async_set_cache = AsyncMock() # Mock cache set for _cache_team_object + mock_cache.async_set_cache = ( + AsyncMock() + ) # Mock cache set for _cache_team_object if should_succeed: # Should NOT raise an exception @@ -5487,7 +5665,7 @@ async def test_update_team_soft_budget_validation( ) # Verify exception details - assert exc_info.value.code == '400' + assert exc_info.value.code == "400" if error_message: assert error_message in str(exc_info.value.message) @@ -5498,12 +5676,10 @@ async def test_new_team_positive_budgets_accepted(): Test that NewTeamRequest accepts positive budget values. """ from litellm.proxy._types import NewTeamRequest - + # Should not raise any errors request = NewTeamRequest( - team_alias="test-team", - max_budget=100.0, - team_member_budget=50.0 + team_alias="test-team", max_budget=100.0, team_member_budget=50.0 ) assert request.max_budget == 100.0 assert request.team_member_budget == 50.0 @@ -5638,9 +5814,7 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( user_api_key_2.token = "user_key_2" # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( return_value=[user_api_key_1, user_api_key_2] ) @@ -5726,9 +5900,7 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) } # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) # Mock get_user_object with patch( @@ -5764,9 +5936,10 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) assert call_kwargs["entity_id"] == [team_id] # Verify user's API keys were NOT fetched (since they're admin) - if hasattr( - mock_db_client.db.litellm_verificationtoken, "find_many" - ) and mock_db_client.db.litellm_verificationtoken.find_many.called: + if ( + hasattr(mock_db_client.db.litellm_verificationtoken, "find_many") + and mock_db_client.db.litellm_verificationtoken.find_many.called + ): # If it was called, that's unexpected for admin users assert False, "API keys should not be fetched for team admin users" @@ -5815,9 +5988,7 @@ async def test_get_team_daily_activity_member_with_permission_sees_all_spend( } # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) # Mock get_user_object with patch( @@ -5853,9 +6024,10 @@ async def test_get_team_daily_activity_member_with_permission_sees_all_spend( assert call_kwargs["entity_id"] == [team_id] # Verify user's API keys were NOT fetched - if hasattr( - mock_db_client.db.litellm_verificationtoken, "find_many" - ) and mock_db_client.db.litellm_verificationtoken.find_many.called: + if ( + hasattr(mock_db_client.db.litellm_verificationtoken, "find_many") + and mock_db_client.db.litellm_verificationtoken.find_many.called + ): assert ( False ), "API keys should not be fetched for members with /team/daily/activity permission" @@ -5911,9 +6083,7 @@ async def test_get_team_daily_activity_member_without_permission_filters_by_keys user_api_key_2.token = "user_key_def" # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( return_value=[user_api_key_1, user_api_key_2] ) @@ -6081,9 +6251,7 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( user_api_key_2.token = "user_key_2" # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( return_value=[user_api_key_1, user_api_key_2] ) @@ -6169,9 +6337,7 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) } # Setup mocks - mock_db_client.db.litellm_teamtable.find_many = AsyncMock( - return_value=[mock_team] - ) + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) # Mock get_user_object with patch( @@ -6207,9 +6373,10 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) assert call_kwargs["entity_id"] == [team_id] # Verify user's API keys were NOT fetched (since they're admin) - if hasattr( - mock_db_client.db.litellm_verificationtoken, "find_many" - ) and mock_db_client.db.litellm_verificationtoken.find_many.called: + if ( + hasattr(mock_db_client.db.litellm_verificationtoken, "find_many") + and mock_db_client.db.litellm_verificationtoken.find_many.called + ): # If it was called, that's unexpected for admin users assert False, "API keys should not be fetched for team admin users" @@ -6222,28 +6389,28 @@ async def test_validate_and_populate_member_user_info_both_provided_match(): """ # Create member with both user_email and user_id member = Member(user_email="test@example.com", user_id="user-123", role="user") - + # Mock prisma client mock_prisma_client = MagicMock() - + # Mock user object that matches both email and user_id mock_user = MagicMock() mock_user.user_id = "user-123" mock_user.user_email = "test@example.com" - + # Mock get_data to return single user matching email mock_prisma_client.get_data = AsyncMock(return_value=[mock_user]) - + # Call the function result = await _validate_and_populate_member_user_info( member=member, prisma_client=mock_prisma_client, ) - + # Verify result matches input (both already provided and match) assert result.user_email == "test@example.com" assert result.user_id == "user-123" - + # Verify get_data was called with correct parameters mock_prisma_client.get_data.assert_called_once_with( key_val={"user_email": "test@example.com"}, @@ -6260,38 +6427,38 @@ async def test_validate_and_populate_member_user_info_only_email_provided(): """ # Create member with only user_email member = Member(user_email="test@example.com", user_id=None, role="user") - + # Mock prisma client mock_prisma_client = MagicMock() - + # Mock user object from find_first mock_user_find_first = MagicMock() mock_user_find_first.user_id = "user-456" mock_user_find_first.user_email = "test@example.com" - + # Mock find_first to return the user mock_prisma_client.db.litellm_usertable.find_first = AsyncMock( return_value=mock_user_find_first ) - + # Mock get_data to return single user (no duplicates) mock_prisma_client.get_data = AsyncMock(return_value=[mock_user_find_first]) - + # Call the function result = await _validate_and_populate_member_user_info( member=member, prisma_client=mock_prisma_client, ) - + # Verify user_id was populated assert result.user_email == "test@example.com" assert result.user_id == "user-456" - + # Verify find_first was called with correct parameters mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with( where={"user_email": {"equals": "test@example.com", "mode": "insensitive"}} ) - + # Verify get_data was called to check for duplicates mock_prisma_client.get_data.assert_called_once_with( key_val={"user_email": "test@example.com"}, @@ -6309,24 +6476,24 @@ async def test_validate_and_populate_member_user_info_only_user_id_not_found(): """ # Create member with only user_id member = Member(user_email=None, user_id="nonexistent-user", role="user") - + # Mock prisma client mock_prisma_client = MagicMock() - + # Mock find_unique to return None (user not found) mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) - + # Call the function - should NOT raise an exception result = await _validate_and_populate_member_user_info( member=member, prisma_client=mock_prisma_client, ) - + # Verify the result - should return member with user_id set and user_email as None assert result.user_id == "nonexistent-user" assert result.user_email is None assert result.role == "user" - + # Verify find_unique was called with correct parameters mock_prisma_client.db.litellm_usertable.find_unique.assert_called_once_with( where={"user_id": "nonexistent-user"} @@ -6344,9 +6511,7 @@ async def test_list_available_teams_returns_empty_list_when_none_configured(): mock_request = MagicMock() mock_user_key = UserAPIKeyAuth(user_id="test-user", token="fake-token") - with patch( - "litellm.proxy.proxy_server.prisma_client", mock_prisma_client - ): + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): # Case 1: default_internal_user_params is None original = litellm.default_internal_user_params litellm.default_internal_user_params = None @@ -6378,10 +6543,8 @@ async def test_list_team_v1_batches_key_queries(): from fastapi import Request from litellm.proxy._types import ( - LiteLLM_TeamMembership, LiteLLM_TeamTable, LitellmUserRoles, - TeamListResponseObject, UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.team_endpoints import list_team @@ -6405,9 +6568,7 @@ async def test_list_team_v1_batches_key_queries(): key3 = MagicMock() key3.team_id = "team-2" - with patch( - "litellm.proxy.proxy_server.prisma_client" - ) as mock_prisma_client, patch( + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( "litellm.proxy.management_endpoints.team_endpoints._authorize_and_filter_teams", new_callable=AsyncMock, return_value=[team1, team2], @@ -6416,6 +6577,7 @@ async def test_list_team_v1_batches_key_queries(): new_callable=AsyncMock, return_value=[], ): + async def filtered_find_many(**kwargs): where = kwargs.get("where", {}) tid = where.get("team_id") @@ -6460,12 +6622,12 @@ async def test_create_team_member_budget_table_with_duration(): """Verify that create_team_member_budget_table passes budget_duration through to the new_budget call when team_member_budget_duration is provided.""" from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth, LitellmUserRoles - from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) mock_budget_response = MagicMock(budget_id="budget-abc") - mock_admin = UserAPIKeyAuth( - user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN - ) + mock_admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) data = NewTeamRequest( team_alias="test-team",