diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index b6bef568637..ee97b3c5373 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -2251,6 +2251,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
description="sends alerts if requests hang for 5min+",
)
ui_access_mode: Optional[Literal["admin_only", "all"]] = Field("all", description="Control access to the Proxy UI")
+ allow_user_team_creation: bool = Field(
+ default=False,
+ description="When True, internal users can call POST /team/new to create standalone teams (no organization_id). The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.",
+ )
allowed_routes: Optional[List] = Field(None, description="Proxy API Endpoints you want users to be able to access")
reject_clientside_metadata_tags: Optional[bool] = Field(
None,
diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py
index dd0a34a7898..8d31d07ad9e 100644
--- a/litellm/proxy/auth/route_checks.py
+++ b/litellm/proxy/auth/route_checks.py
@@ -288,6 +288,13 @@ class RouteChecks:
request_data=request_data,
request=request,
)
+ elif (
+ _user_role == LitellmUserRoles.INTERNAL_USER.value
+ and route == "/team/new"
+ and request_data.get("organization_id") is None
+ and RouteChecks._user_team_creation_enabled()
+ ):
+ pass
elif _user_role == LitellmUserRoles.INTERNAL_USER.value and RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value
):
@@ -326,6 +333,12 @@ class RouteChecks:
else:
RouteChecks._raise_admin_only_route_exception(user_obj=user_obj, route=route)
+ @staticmethod
+ def _user_team_creation_enabled() -> bool:
+ from litellm.proxy.proxy_server import general_settings
+
+ return general_settings.get("allow_user_team_creation", False) is True
+
@staticmethod
def custom_admin_only_route_check(route: str):
from litellm.proxy.proxy_server import general_settings, premium_user
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 8ec7ec707a2..f8637d968f2 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -74,6 +74,7 @@ from litellm.proxy.auth.auth_checks import (
get_team_object,
get_user_object,
)
+from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.management_endpoints.common_utils import (
@@ -784,6 +785,42 @@ async def _check_org_team_limits(
)
+def _tightest_cap(*caps: Optional[float]) -> Optional[float]:
+ set_caps = tuple(cap for cap in caps if cap is not None)
+ return min(set_caps) if set_caps else None
+
+
+def _inherit_caller_limits_for_self_served_team(
+ data: NewTeamRequest,
+ user_api_key_dict: UserAPIKeyAuth,
+) -> NewTeamRequest:
+ """Clamp a self-served team to the creating user's effective limits.
+
+ A self-served team must never be wider than its creator. The creator's
+ authority spans two layers: the calling key (`tpm_limit`/`rpm_limit`/
+ `models`) and the underlying user (`user_tpm_limit`/`user_rpm_limit`/
+ `user_max_budget`). The primary caller is a UI SSO session whose key
+ carries no tpm/rpm and only a tiny per-session `max_budget`
+ (`max_ui_session_budget`), so reading key limits alone would leave the team
+ uncapped. We take the tightest of both layers per dimension and clamp the
+ team down to it, filling unset fields and shrinking any value (including one
+ seeded by `default_team_params`) that exceeds the creator's ceiling. The
+ per-session key budget is deliberately excluded from the budget cap; only
+ the user's own `max_budget` bounds the team.
+ """
+ effective_tpm = _tightest_cap(user_api_key_dict.tpm_limit, user_api_key_dict.user_tpm_limit)
+ effective_rpm = _tightest_cap(user_api_key_dict.rpm_limit, user_api_key_dict.user_rpm_limit)
+ effective_budget = user_api_key_dict.user_max_budget
+ return data.model_copy(
+ update={
+ "models": data.models if data.models else list(user_api_key_dict.models),
+ "tpm_limit": _tightest_cap(data.tpm_limit, effective_tpm),
+ "rpm_limit": _tightest_cap(data.rpm_limit, effective_rpm),
+ "max_budget": _tightest_cap(data.max_budget, effective_budget),
+ }
+ )
+
+
async def _check_user_team_limits(
data: Union[NewTeamRequest, UpdateTeamRequest],
user_api_key_dict: UserAPIKeyAuth,
@@ -1115,6 +1152,14 @@ async def new_team(
# Only validate user budget/models/tpm/rpm for standalone teams (not org-scoped)
# For org-scoped teams, validation is done by _check_org_team_limits()
if data.organization_id is None:
+ if (
+ user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER
+ and RouteChecks._user_team_creation_enabled()
+ ):
+ data = _inherit_caller_limits_for_self_served_team(
+ data=data,
+ user_api_key_dict=user_api_key_dict,
+ )
await _check_user_team_limits(
data=data,
user_api_key_dict=user_api_key_dict,
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index a8926d26047..ace07b5b9d5 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -182,6 +182,11 @@ class UISettings(BaseModel):
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
+ allow_user_team_creation: bool = Field(
+ default=False,
+ description="If true, internal users can create their own standalone teams via POST /team/new and the Teams page. The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.",
+ )
+
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@@ -206,6 +211,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
+ "allow_user_team_creation",
}
# Flags that must be synced from the persisted UISettings into
@@ -219,6 +225,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"disable_key_generate_for_org_admin",
+ "allow_user_team_creation",
]
# Extension point: packages outside OSS (e.g. litellm_enterprise) can
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 204e6a671e3..d88d49ef0a9 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -2926,3 +2926,109 @@ def test_internal_user_blocked_from_search_tool_writes(route):
assert "Only proxy admin" in str(exc_info.value)
assert f"Route={route}" in str(exc_info.value)
assert "Your role=internal_user" in str(exc_info.value)
+
+
+def _self_serve_team_new_check(
+ route: str,
+ general_settings: dict,
+ role: str = LitellmUserRoles.INTERNAL_USER.value,
+ request_data: dict | None = None,
+):
+ """Run non_proxy_admin_allowed_routes_check for the LIT-3254 self-serve
+ team creation gate with the given general_settings patched in."""
+ user_obj = LiteLLM_UserTable(
+ user_id="self-serve-user",
+ user_email="user@example.com",
+ user_role=role,
+ )
+ valid_token = UserAPIKeyAuth(
+ user_id="self-serve-user",
+ user_role=role,
+ )
+ request = MagicMock(spec=Request)
+ request.query_params = {}
+ request.method = "POST"
+ with (
+ patch("litellm.proxy.proxy_server.general_settings", general_settings),
+ patch("litellm.proxy.proxy_server.premium_user", True),
+ ):
+ return RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=user_obj,
+ _user_role=role,
+ route=route,
+ request=request,
+ valid_token=valid_token,
+ request_data=request_data if request_data is not None else {},
+ )
+
+
+@pytest.mark.parametrize(
+ "general_settings",
+ [{}, {"allow_user_team_creation": False}, {"allow_user_team_creation": "yes"}],
+)
+def test_internal_user_team_new_blocked_unless_flag_enabled(general_settings):
+ """LIT-3254: /team/new stays admin-only when allow_user_team_creation is
+ absent, False, or any non-boolean truthy value."""
+ with pytest.raises(Exception) as exc_info:
+ _self_serve_team_new_check("/team/new", general_settings)
+ assert "Only proxy admin" in str(exc_info.value)
+
+
+@pytest.mark.parametrize(
+ "request_data",
+ [{}, {"organization_id": None}, {"team_alias": "my-team"}],
+)
+def test_internal_user_team_new_allowed_when_flag_enabled(request_data):
+ """LIT-3254: with the flag on, an internal user may create a standalone
+ team (no organization_id in the request body)."""
+ result = _self_serve_team_new_check(
+ "/team/new",
+ {"allow_user_team_creation": True},
+ request_data=request_data,
+ )
+ assert result is None
+
+
+def test_internal_user_team_new_with_organization_id_blocked_despite_flag():
+ """LIT-3254 cross-org bypass guard: the self-serve gate must not admit
+ org-scoped requests. new_team only checks that the organization row
+ exists, not that the caller belongs to it, so an org-scoped request from
+ a non-org-admin must keep failing at the route layer."""
+ with pytest.raises(Exception) as exc_info:
+ _self_serve_team_new_check(
+ "/team/new",
+ {"allow_user_team_creation": True},
+ request_data={"organization_id": "org-i-dont-belong-to"},
+ )
+ assert "Only proxy admin" in str(exc_info.value)
+
+
+@pytest.mark.parametrize(
+ "route",
+ ["/team/delete", "/team/update", "/team/block", "/user/new", "/organization/new"],
+)
+def test_flag_scoped_to_team_new_only(route):
+ """LIT-3254: the flag opens exactly one route. Every other management
+ route must keep raising for an internal user even with the flag on."""
+ with pytest.raises(Exception) as exc_info:
+ _self_serve_team_new_check(route, {"allow_user_team_creation": True})
+ assert "Only proxy admin" in str(exc_info.value)
+
+
+@pytest.mark.parametrize(
+ "role",
+ [
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
+ LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
+ LitellmUserRoles.TEAM.value,
+ ],
+)
+def test_flag_scoped_to_internal_user_role_only(role):
+ """LIT-3254: view-only and team-scoped roles must stay blocked from
+ /team/new regardless of allow_user_team_creation."""
+ with pytest.raises(Exception):
+ _self_serve_team_new_check(
+ "/team/new",
+ {"allow_user_team_creation": True},
+ role=role,
+ )
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 180fb1d3d8f..c9d7a7ea6b1 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -9535,3 +9535,306 @@ async def test_new_team_rejects_reserved_ui_session_team_id():
assert exc_info.value.code == "400"
assert "reserved" in str(exc_info.value.message)
mock_prisma.get_data.assert_not_called()
+
+
+class TestSelfServeTeamLimitInheritance:
+ """LIT-3254: _inherit_caller_limits_for_self_served_team fills unset team
+ fields from the caller so a self-served team can never be wider than its
+ creator."""
+
+ def _caller(self, **kwargs):
+ from litellm.proxy._types import UserAPIKeyAuth
+
+ return UserAPIKeyAuth(api_key="sk-test", user_id="u1", **kwargs)
+
+ def test_inherits_models_when_unset_or_empty(self):
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(models=["gpt-5", "gpt-5-mini"])
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t", models=[]),
+ user_api_key_dict=caller,
+ )
+ assert result.models == ["gpt-5", "gpt-5-mini"]
+
+ def test_inherits_key_tpm_and_rpm_when_unset(self):
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(tpm_limit=1000, rpm_limit=10)
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t"),
+ user_api_key_dict=caller,
+ )
+ assert result.tpm_limit == 1000
+ assert result.rpm_limit == 10
+
+ def test_inherits_user_level_limits_when_key_has_none(self):
+ """Finding 7: a UI SSO session key carries no tpm/rpm and only a tiny
+ per-session budget, so the team must inherit the USER-level caps or it
+ is created uncapped despite a rate/budget-limited creator."""
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(
+ tpm_limit=None,
+ rpm_limit=None,
+ max_budget=0.25, # ephemeral UI-session budget, must NOT propagate
+ user_tpm_limit=100,
+ user_rpm_limit=5,
+ user_max_budget=10.0,
+ )
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t"),
+ user_api_key_dict=caller,
+ )
+ assert result.tpm_limit == 100
+ assert result.rpm_limit == 5
+ assert result.max_budget == 10.0
+
+ def test_uses_tightest_of_key_and_user_limits(self):
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(tpm_limit=100, user_tpm_limit=1000, rpm_limit=50, user_rpm_limit=5)
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t"),
+ user_api_key_dict=caller,
+ )
+ assert result.tpm_limit == 100
+ assert result.rpm_limit == 5
+
+ def test_clamps_over_cap_values_down_to_caller(self):
+ """A value seeded by default_team_params (or an explicit over-request)
+ that exceeds the creator's ceiling is clamped, not rejected — so the
+ feature stays usable when an admin default is higher than a user cap."""
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(user_tpm_limit=100, user_rpm_limit=5, user_max_budget=10.0)
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t", tpm_limit=100000, rpm_limit=9999, max_budget=1000.0),
+ user_api_key_dict=caller,
+ )
+ assert result.tpm_limit == 100
+ assert result.rpm_limit == 5
+ assert result.max_budget == 10.0
+
+ def test_under_cap_values_preserved(self):
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(models=["a", "b"], user_tpm_limit=5000, user_rpm_limit=100, user_max_budget=100.0)
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t", models=["a"], tpm_limit=1000, rpm_limit=50, max_budget=25.0),
+ user_api_key_dict=caller,
+ )
+ assert result.models == ["a"]
+ assert result.tpm_limit == 1000
+ assert result.rpm_limit == 50
+ assert result.max_budget == 25.0
+
+ def test_unrestricted_caller_leaves_team_unrestricted(self):
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(
+ models=[],
+ tpm_limit=None,
+ rpm_limit=None,
+ user_tpm_limit=None,
+ user_rpm_limit=None,
+ user_max_budget=None,
+ )
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t"),
+ user_api_key_dict=caller,
+ )
+ assert result.models == []
+ assert result.tpm_limit is None
+ assert result.rpm_limit is None
+ assert result.max_budget is None
+
+ def test_session_key_budget_not_propagated(self):
+ """The tiny per-session key budget must never become the team budget;
+ only the user's own max_budget bounds the team."""
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ _inherit_caller_limits_for_self_served_team,
+ )
+
+ caller = self._caller(max_budget=0.25, user_max_budget=None)
+ result = _inherit_caller_limits_for_self_served_team(
+ data=NewTeamRequest(team_alias="t"),
+ user_api_key_dict=caller,
+ )
+ assert result.max_budget is None
+
+
+@pytest.mark.parametrize(
+ "flag_enabled,caller_role,expect_inherited",
+ [
+ (True, LitellmUserRoles.INTERNAL_USER, True),
+ (False, LitellmUserRoles.INTERNAL_USER, False),
+ (True, LitellmUserRoles.ORG_ADMIN, False),
+ ],
+)
+@pytest.mark.asyncio
+async def test_new_team_self_serve_inheritance_call_site(flag_enabled, caller_role, expect_inherited):
+ """LIT-3254: new_team applies caller-limit inheritance exactly when
+ allow_user_team_creation is on AND the caller is an INTERNAL_USER.
+ Org admins and flag-off callers keep today's behavior (an omitted models
+ list stays empty)."""
+ from fastapi import Request
+
+ from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.team_endpoints import new_team
+
+ caller = UserAPIKeyAuth(
+ user_role=caller_role,
+ user_id="self-serve-user-1",
+ models=["gpt-5"],
+ tpm_limit=1000,
+ rpm_limit=10,
+ )
+ team_request = NewTeamRequest(team_alias="self-serve-team")
+ dummy_request = MagicMock(spec=Request)
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
+ patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
+ patch("litellm.proxy.proxy_server._license_check") as mock_license,
+ patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
+ patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()),
+ patch(
+ "litellm.proxy.proxy_server.general_settings",
+ {"allow_user_team_creation": flag_enabled},
+ ),
+ ):
+ 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_cache.async_get_cache = AsyncMock(return_value=None)
+
+ mock_created_team = MagicMock()
+ mock_created_team.team_id = "team-self-serve-1"
+ mock_created_team.members_with_roles = []
+ mock_created_team.metadata = None
+ mock_created_team.default_team_member_models = None
+ mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-1"}
+ 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_modeltable = MagicMock()
+ mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123"))
+
+ mock_user = MagicMock()
+ mock_user.user_id = "self-serve-user-1"
+ mock_user.model_dump.return_value = {
+ "user_id": "self-serve-user-1",
+ "teams": ["team-self-serve-1"],
+ }
+ 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_membership = MagicMock()
+ mock_membership.model_dump.return_value = {
+ "team_id": "team-self-serve-1",
+ "user_id": "self-serve-user-1",
+ "budget_id": None,
+ }
+ mock_prisma.db.litellm_teammembership = MagicMock()
+ mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership)
+
+ await new_team(
+ data=team_request,
+ http_request=dummy_request,
+ user_api_key_dict=caller,
+ )
+
+ created_row = mock_prisma.db.litellm_teamtable.create.call_args.kwargs["data"]
+ if expect_inherited:
+ assert created_row["models"] == ["gpt-5"]
+ assert created_row["tpm_limit"] == 1000
+ assert created_row["rpm_limit"] == 10
+ else:
+ assert created_row["models"] == []
+ assert created_row.get("tpm_limit") is None
+ assert created_row.get("rpm_limit") is None
+
+
+@pytest.mark.asyncio
+async def test_new_team_self_serve_creator_becomes_team_admin():
+ """LIT-3254: a self-served team must include its creator as a team admin
+ in members_with_roles."""
+ from fastapi import Request
+
+ from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.team_endpoints import new_team
+
+ caller = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.INTERNAL_USER,
+ user_id="self-serve-user-2",
+ models=[],
+ )
+ team_request = NewTeamRequest(team_alias="self-serve-admin-team")
+ dummy_request = MagicMock(spec=Request)
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
+ patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
+ patch("litellm.proxy.proxy_server._license_check") as mock_license,
+ patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
+ patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()),
+ patch(
+ "litellm.proxy.proxy_server.general_settings",
+ {"allow_user_team_creation": True},
+ ),
+ patch(
+ "litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team",
+ new=AsyncMock(),
+ ) as mock_add_members,
+ ):
+ 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_cache.async_get_cache = AsyncMock(return_value=None)
+
+ mock_created_team = MagicMock()
+ mock_created_team.team_id = "team-self-serve-2"
+ mock_created_team.members_with_roles = []
+ mock_created_team.metadata = None
+ mock_created_team.default_team_member_models = None
+ mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-2"}
+ 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_modeltable = MagicMock()
+ mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123"))
+
+ await new_team(
+ data=team_request,
+ http_request=dummy_request,
+ user_api_key_dict=caller,
+ )
+
+ add_request = mock_add_members.call_args.kwargs["data"]
+ assert [(m.user_id, m.role) for m in add_request.member] == [("self-serve-user-2", "admin")]
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index 69845ec59c2..2986b8f29ec 100644
--- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -2393,3 +2393,19 @@ def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
assert "proxy admin" in resp.json()["detail"].lower()
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
+
+
+def test_allow_user_team_creation_is_wired_into_ui_settings_sync():
+ """LIT-3254: the allow_user_team_creation UI toggle only reaches the
+ /team/new route gate through the general_settings runtime sync. If the
+ flag drops out of either list, the DB-persisted toggle silently stops
+ working while the UI still shows it as enabled."""
+ from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
+ ALLOWED_UI_SETTINGS_FIELDS,
+ _RUNTIME_GENERAL_SETTINGS_FLAGS,
+ UISettings,
+ )
+
+ assert "allow_user_team_creation" in ALLOWED_UI_SETTINGS_FIELDS
+ assert "allow_user_team_creation" in _RUNTIME_GENERAL_SETTINGS_FLAGS
+ assert UISettings.model_fields["allow_user_team_creation"].default is False
diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
index 0b6e5786aaf..b0ddd417290 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
@@ -3,8 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
-import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
-import OldTeams from "./OldTeams";
+import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall, type Organization } from "./networking";
+import OldTeams, { canCreateOrManageTeams } from "./OldTeams";
import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
const mockTeamInfoView = vi.fn();
@@ -95,6 +95,12 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => mockUseOrganizations(),
}));
+const mockUseUISettings = vi.fn();
+
+vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
+ useUISettings: () => mockUseUISettings() ?? { data: undefined },
+}));
+
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: vi.fn().mockReturnValue({
data: [
@@ -131,6 +137,17 @@ const renderWithQueryClient = (component: React.ReactElement) => {
return render({component});
};
+const emptyTeamListResult = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 };
+const selfServeCreatedTeam = {
+ team_id: "new-team-1",
+ team_alias: "My Self Serve Team",
+ models: ["gpt-4"],
+ organization_id: null,
+ keys: [],
+ members_with_roles: [],
+ spend: 0,
+};
+
describe("OldTeams - handleCreate organization handling", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -529,48 +546,39 @@ describe("OldTeams - helper functions", () => {
});
describe("canCreateOrManageTeams", () => {
- it("should return true for Admin role", () => {
- const userRole = "Admin";
- const result = userRole === "Admin";
- expect(result).toBe(true);
+ const orgWithOrgAdmin = {
+ organization_id: "org-1",
+ organization_alias: "Org 1",
+ models: [],
+ members: [{ user_id: "user-123", user_role: "org_admin" }],
+ } as unknown as Organization;
+
+ const orgWithPlainMember = {
+ organization_id: "org-1",
+ organization_alias: "Org 1",
+ models: [],
+ members: [{ user_id: "user-123", user_role: "member" }],
+ } as unknown as Organization;
+
+ it("returns true for Admin role regardless of the self-serve flag", () => {
+ expect(canCreateOrManageTeams("Admin", "user-123", null, false)).toBe(true);
});
- it("should return true for org_admin in any organization", () => {
- const userID = "user-123";
- const organizations = [
- {
- organization_id: "org-1",
- organization_alias: "Org 1",
- models: [],
- members: [{ user_id: "user-123", user_role: "org_admin" }],
- },
- ];
-
- const result = organizations.some((org) =>
- org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
- );
-
- expect(result).toBe(true);
+ it("returns true for org_admin in any organization regardless of the self-serve flag", () => {
+ expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithOrgAdmin], false)).toBe(true);
});
- it("should return false when user has no admin permissions", () => {
- const userID = "user-123";
- const userRole: string = "User";
- const organizations = [
- {
- organization_id: "org-1",
- organization_alias: "Org 1",
- models: [],
- members: [{ user_id: "user-123", user_role: "member" }],
- },
- ];
+ it("returns true for Internal User when allow_user_team_creation is on", () => {
+ expect(canCreateOrManageTeams("Internal User", "user-123", null, true)).toBe(true);
+ });
- const isAdmin = userRole === "Admin";
- const isOrgAdmin = organizations.some((org) =>
- org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
- );
+ it("returns false for Internal User when allow_user_team_creation is off", () => {
+ expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithPlainMember], false)).toBe(false);
+ });
- expect(isAdmin || isOrgAdmin).toBe(false);
+ it("does not extend the self-serve flag to viewer roles", () => {
+ expect(canCreateOrManageTeams("Internal Viewer", "user-123", null, true)).toBe(false);
+ expect(canCreateOrManageTeams("Admin Viewer", "user-123", null, true)).toBe(false);
});
});
});
@@ -1146,3 +1154,97 @@ describe("OldTeams - LIT-2530 organization stays optional for proxy admin with a
});
});
});
+
+describe("OldTeams - self-serve team creation (LIT-3254)", () => {
+ beforeEach(() => {
+ mockTeamInfoView.mockClear();
+ vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
+ vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
+ vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
+ vi.mocked(teamListCall).mockResolvedValue(emptyTeamListResult);
+ mockUseOrganizations.mockReturnValue({ data: [] });
+ });
+
+ it("shows the Create Team button to an Internal User when allow_user_team_creation is on", async () => {
+ mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
+
+ renderWithQueryClient();
+
+ const buttons = await screen.findAllByTestId("create-team-button");
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it("hides the Create Team button from an Internal User when allow_user_team_creation is off", async () => {
+ mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: false } } });
+
+ renderWithQueryClient();
+
+ await waitFor(() => expect(screen.getAllByText("Teams").length).toBeGreaterThan(0));
+ expect(screen.queryByTestId("create-team-button")).not.toBeInTheDocument();
+ });
+
+ it("omits the Organization selector for a self-serve Internal User with no admin orgs", async () => {
+ mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
+
+ renderWithQueryClient();
+
+ const buttons = await screen.findAllByTestId("create-team-button");
+ fireEvent.click(buttons[0]);
+
+ await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument());
+ expect(document.getElementById("organization_id")).toBeNull();
+ });
+
+ it("submits a standalone team creation for a self-serve Internal User", async () => {
+ mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
+ vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
+ vi.mocked(teamCreateCall).mockResolvedValue(selfServeCreatedTeam);
+
+ renderWithQueryClient();
+
+ const buttons = await screen.findAllByTestId("create-team-button");
+ act(() => {
+ fireEvent.click(buttons[0]);
+ });
+
+ await waitFor(() => expect(screen.getByLabelText(/team name/i)).toBeInTheDocument());
+ fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "My Self Serve Team" } });
+ fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
+
+ const submitButtons = screen.getAllByRole("button", { name: /create team/i });
+ fireEvent.click(submitButtons[submitButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(teamCreateCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ team_alias: "My Self Serve Team",
+ models: ["gpt-4"],
+ organization_id: null,
+ }),
+ );
+ });
+ });
+
+ it("keeps the required Organization selector for an org admin when the flag is on", async () => {
+ mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
+ mockUseOrganizations.mockReturnValue({
+ data: [
+ {
+ organization_id: "org-1",
+ organization_alias: "Org 1",
+ models: [],
+ members: [{ user_id: "user-123", user_role: "org_admin" }],
+ },
+ ],
+ });
+
+ renderWithQueryClient();
+
+ const buttons = await screen.findAllByTestId("create-team-button");
+ fireEvent.click(buttons[0]);
+
+ await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument());
+ expect(document.getElementById("organization_id")).not.toBeNull();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx
index e83d1acf4e5..48c81c2366d 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.tsx
@@ -1,4 +1,5 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
+import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import AvailableTeamsPanel from "@/components/team/available_teams";
import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
@@ -108,16 +109,21 @@ const getOrganizationModels = (organization: Organization | null, userModels: st
return unfurlWildcardModelsInList(tempModelsToPick, userModels);
};
-const canCreateOrManageTeams = (
+export const canCreateOrManageTeams = (
userRole: string | null,
userID: string | null,
organizations: Organization[] | null,
+ allowUserTeamCreation: boolean,
): boolean => {
// Admin role always has permission
if (userRole === "Admin") {
return true;
}
+ if (allowUserTeamCreation && userRole === "Internal User") {
+ return true;
+ }
+
// Check if user is an org_admin in any organization
if (organizations && userID) {
return organizations.some((org) =>
@@ -164,6 +170,8 @@ const getOrganizationAlias = (
const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => {
const { data: organizationsData } = useOrganizations();
const organizations = organizationsData ?? null;
+ const { data: uiSettings } = useUISettings();
+ const allowUserTeamCreation = Boolean(uiSettings?.values?.allow_user_team_creation);
const [teams, setTeams] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState(null);
@@ -879,7 +887,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
Create your first team to organize members and manage access to models.
- {canCreateOrManageTeams(userRole, userID, organizations) && (
+ {canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
}
@@ -1026,7 +1034,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
Manage teams, members, and their access to models and budgets
- {canCreateOrManageTeams(userRole, userID, organizations) && (
+ {canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
}
@@ -1042,7 +1050,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
>
)}
- {canCreateOrManageTeams(userRole, userID, organizations) && (
+ {canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
= ({ accessToken, userID, userRole, premiumUser
const isSingleOrg = adminOrgs.length === 1;
const hasNoOrgs = adminOrgs.length === 0;
+ if (userRole !== "Admin" && hasNoOrgs) {
+ return null;
+ }
+
return (
<>
{
expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully");
});
});
+
+describe("UISettings - allow_user_team_creation (LIT-3254)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
+ mockUseUISettings.mockReturnValue(buildSettingsResponse());
+ mockUseUpdateUISettings.mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ error: null,
+ });
+ });
+
+ it("toggles self-serve team creation on and calls update with the right key", () => {
+ const mutateMock = vi.fn((_settings, options) => {
+ options?.onSuccess?.();
+ });
+
+ mockUseUpdateUISettings.mockReturnValue({
+ mutate: mutateMock,
+ isPending: false,
+ error: null,
+ });
+
+ render();
+
+ const toggle = screen.getByRole("switch", { name: "Allow internal users to create teams" });
+ expect(toggle).not.toBeChecked();
+
+ act(() => {
+ fireEvent.click(toggle);
+ });
+
+ expect(mutateMock).toHaveBeenCalledWith(
+ { allow_user_team_creation: true },
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
+ );
+ expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully");
+ });
+
+ it("toggles self-serve team creation off when currently enabled", () => {
+ const mutateMock = vi.fn();
+
+ mockUseUISettings.mockReturnValue(
+ buildSettingsResponse({
+ data: {
+ field_schema: { description: "UI settings description", properties: {} },
+ values: { allow_user_team_creation: true },
+ },
+ }),
+ );
+ mockUseUpdateUISettings.mockReturnValue({
+ mutate: mutateMock,
+ isPending: false,
+ error: null,
+ });
+
+ render();
+
+ const toggle = screen.getByRole("switch", { name: "Allow internal users to create teams" });
+ expect(toggle).toBeChecked();
+
+ act(() => {
+ fireEvent.click(toggle);
+ });
+
+ expect(mutateMock).toHaveBeenCalledWith({ allow_user_team_creation: false }, expect.anything());
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
index ec970c34873..355a11ff3d6 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
@@ -27,6 +27,7 @@ export default function UISettings() {
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
+ const allowUserTeamCreationProperty = schema?.properties?.allow_user_team_creation;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@@ -228,6 +229,20 @@ export default function UISettings() {
);
};
+ const handleToggleAllowUserTeamCreation = (checked: boolean) => {
+ updateSettings(
+ { allow_user_team_creation: checked },
+ {
+ onSuccess: () => {
+ NotificationManager.success("UI settings updated successfully");
+ },
+ onError: (error) => {
+ NotificationManager.fromBackend(error);
+ },
+ },
+ );
+ };
+
return (
{isLoading ? (
@@ -484,6 +499,25 @@ export default function UISettings() {
+
+
+
+ Allow internal users to create teams
+
+ {allowUserTeamCreationProperty?.description ??
+ "If true, internal users can create their own standalone teams. The creating user is automatically added as the team's admin."}
+
+
+
+
+
+
{/* Page Visibility for Internal Users */}