From f3bbeed82ff9a9936e1227018dfe794ce778f0dd Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 19 Sep 2026 01:02:50 +0000 Subject: [PATCH] feat(proxy): let team admins manage projects via team_admin_editable_team_fields Adds a projects entry to the team_admin_editable_team_fields setting. When set, team admins (legacy admins list or members_with_roles role admin) can call /project/new and /project/update for the teams they administer. The two routes join self_managed_routes so the endpoint check runs instead of the route gate's blanket 401. /project/delete stays proxy admin only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/project_endpoints.py | 43 ++++--- litellm/proxy/_types.py | 3 + .../team_admin_field_permissions.py | 17 ++- .../proxy_setting_endpoints.py | 11 +- .../proxy/auth/test_route_checks.py | 34 ++++++ .../test_project_org_authz.py | 111 +++++++++++++++++- .../test_team_admin_field_permissions.py | 20 ++++ .../test_proxy_setting_endpoints.py | 23 +++- .../team/teamAdminEditAccess.test.ts | 1 + .../components/team/teamAdminEditAccess.ts | 1 + 10 files changed, 233 insertions(+), 31 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index f40ced302ce..2114dfd9849 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -11,7 +11,7 @@ Endpoints for /project operations #### PROJECT MANAGEMENT #### import json -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Final from fastapi import APIRouter, Depends, HTTPException, Request @@ -22,7 +22,11 @@ from litellm._uuid import uuid from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import delete_cached_project_object from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field +from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # shared owner of team-admin membership + _set_object_metadata_field, +) +from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects from litellm.proxy.management_helpers.utils import ( management_endpoint_wrapper, ) @@ -82,37 +86,38 @@ async def _check_user_permission_for_project( user_api_key_dict: UserAPIKeyAuth, team_id: str | None, prisma_client: PrismaClient, + general_settings: Mapping[str, object], require_admin: bool = False, team_object: LiteLLM_TeamTable | None = None, ) -> bool: """ Check if user has permission to manage a project. - Returns True if user is proxy admin or team admin (when team_id provided). + Returns True if user is proxy admin, or a team admin of ``team_id`` when the + ``team_admin_editable_team_fields`` setting grants team admins the ``projects`` permission. If require_admin=True, only proxy admins are allowed. If team_object is provided, it will be used instead of fetching from DB (avoids duplicate DB queries when team was already fetched for validation). """ - is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - if require_admin: + if require_admin or is_proxy_admin: return is_proxy_admin - if is_proxy_admin: - return True - - if not team_id or not user_api_key_dict.user_id: + if not team_id or not user_api_key_dict.user_id or not team_admin_may_manage_projects(general_settings): return False - team = team_object - if team is None: - team = await _team_table(prisma_client).find_unique(where={"team_id": team_id}) + team_row: Final = ( + team_object + if team_object is not None + else await _team_table(prisma_client).find_unique(where={"team_id": team_id}) + ) + if team_row is None: + return False - if team and team.admins: - return user_api_key_dict.user_id in team.admins - - return False + team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) + return _is_user_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or []) async def _validate_team_exists( @@ -531,6 +536,7 @@ async def new_project( user_api_key_dict=user_api_key_dict, team_id=data.team_id, prisma_client=prisma_client, + general_settings=general_settings, team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()), ) @@ -735,6 +741,7 @@ async def update_project( user_api_key_dict=user_api_key_dict, team_id=existing_project.team_id, prisma_client=prisma_client, + general_settings=general_settings, ) if not has_permission: @@ -751,6 +758,7 @@ async def update_project( user_api_key_dict=user_api_key_dict, team_id=data.team_id, prisma_client=prisma_client, + general_settings=general_settings, team_object=( LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()) if target_team_obj else None ), @@ -877,7 +885,7 @@ async def delete_project( }' ``` """ - from litellm.proxy.proxy_server import premium_user, prisma_client, user_api_key_cache + from litellm.proxy.proxy_server import general_settings, premium_user, prisma_client, user_api_key_cache try: if not premium_user: @@ -899,6 +907,7 @@ async def delete_project( user_api_key_dict=user_api_key_dict, team_id=None, prisma_client=prisma_client, + general_settings=general_settings, require_admin=True, ) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0097d4b6e92..dd7d17d0f48 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -897,6 +897,9 @@ class LiteLLMRoutes(enum.Enum): # Project read routes - endpoint scopes results to caller's teams (non-admin) "/project/list", "/project/info", + # Project write routes - endpoint checks team admin + team_admin_editable_team_fields "projects" + "/project/new", + "/project/update", # Endpoint enforces proxy-admin vs team-admin model access itself. "/health/test_connection", # Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index 56d455494c6..6038775d96b 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -1,4 +1,5 @@ -"""Proxy-wide allow-list of team-settings fields a team admin may change on /team/update.""" +"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on +/team/update, plus the ``projects`` permission for /project/new and /project/update.""" from collections.abc import Mapping from dataclasses import dataclass @@ -21,6 +22,10 @@ TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_field # TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"}) +TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects" +SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | { + TEAM_ADMIN_PROJECTS_PERMISSION +} _FIELD_LIST: Final = TypeAdapter(list[str]) _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) @@ -67,17 +72,23 @@ def resolve_team_admin_editable_fields( "%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw ) return frozenset() - unsupported: Final = configured - supported + unsupported: Final = configured - supported - SUPPORTED_TEAM_ADMIN_PERMISSIONS if unsupported: verbose_proxy_logger.warning( "%s ignores unsupported field(s) %s; supported: %s", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, sorted(unsupported), - sorted(supported), + sorted(supported | SUPPORTED_TEAM_ADMIN_PERMISSIONS), ) return configured & supported +def team_admin_may_manage_projects(general_settings: Mapping[str, object]) -> bool: + return TEAM_ADMIN_PROJECTS_PERMISSION in resolve_team_admin_editable_fields( + general_settings, frozenset({TEAM_ADMIN_PROJECTS_PERMISSION}) + ) + + def _as_object(value: object) -> Mapping[str, object]: try: return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index fd160636d46..75431383fbd 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -30,7 +30,7 @@ from litellm.proxy.config_resolvers.sso import ( resolve_sso_config, ) from litellm.proxy.management_endpoints.team_admin_field_permissions import ( - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS, + SUPPORTED_TEAM_ADMIN_PERMISSIONS, TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, ) from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled @@ -216,7 +216,7 @@ class UIThemeSettingsResponse(SettingsResponse): """Response model for UI theme settings""" -_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS)) +_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS)) class UISettings(BaseModel): @@ -315,7 +315,8 @@ class UISettings(BaseModel): default=(), description=( "Team settings fields a team admin may change on the teams they administer. " - "Empty means team admins cannot edit team settings at all. " + "Include 'projects' to let team admins create and update projects for those teams. " + "Empty means team admins cannot edit team settings or manage projects at all. " "Proxy admins and org admins are not affected." ), json_schema_extra={ # mutable-ok: pydantic only merges json_schema_extra when it is a plain dict @@ -1626,7 +1627,7 @@ async def update_ui_settings( raise HTTPException(status_code=422, detail=e.errors()) unsupported_team_fields: Final = sorted( - frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS + frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_PERMISSIONS ) if unsupported_team_fields: raise HTTPException( @@ -1634,7 +1635,7 @@ async def update_ui_settings( detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization "error": ( f"{TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING} does not support {unsupported_team_fields}. " - f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS)}." + f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS)}." ) }, ) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 72c59223549..2e0d2c710a4 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -4038,3 +4038,37 @@ def test_team_key_without_service_account_marker_still_rejected(): valid_token=valid_token, request_data={}, ) + + +@pytest.mark.parametrize("route", ["/project/new", "/project/update"]) +def test_project_write_routes_reach_endpoint_for_internal_user(route): + """The route gate lets a non-admin through so /project/new and /project/update can apply the + team_admin_editable_team_fields projects permission themselves, instead of a blanket 401.""" + valid_token = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=None, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_project_delete_route_stays_proxy_admin_only(): + valid_token = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.query_params = {} + + with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update"): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=None, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/project/delete", + request=request, + valid_token=valid_token, + request_data={}, + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py b/tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py index a06d79306ab..ce08030739d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py +++ b/tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py @@ -7,12 +7,18 @@ Unit tests for the VERIA-55 fixes: member of. """ +from types import MappingProxyType +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.models.team import LiteLLM_TeamTable +from litellm.proxy._types import LitellmUserRoles, Member, UserAPIKeyAuth + +_PROJECTS_ENABLED: Final = MappingProxyType({"team_admin_editable_team_fields": ["projects"]}) +_PROJECTS_DISABLED: Final = MappingProxyType({"team_admin_editable_team_fields": ["max_budget"]}) # --------------------------------------------------------------------------- @@ -20,11 +26,9 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth # --------------------------------------------------------------------------- -def _make_prisma_with_team(team_id: str, admins: list): +def _make_prisma_with_team(team_id: str, admins: list, members_with_roles: tuple[Member, ...] = ()): prisma = MagicMock() - team_row = MagicMock() - team_row.team_id = team_id - team_row.admins = admins + team_row = LiteLLM_TeamTable(team_id=team_id, admins=admins, members_with_roles=list(members_with_roles)) prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) return prisma @@ -49,6 +53,7 @@ async def test_project_perm_check_uses_current_team_not_caller_supplied(): user_api_key_dict=caller, team_id="team-A", prisma_client=prisma, + general_settings=_PROJECTS_ENABLED, ) assert has_perm is False prisma.db.litellm_teamtable.find_unique.assert_awaited_once() @@ -70,10 +75,105 @@ async def test_project_perm_check_allows_team_admin_of_existing_team(): user_api_key_dict=alice, team_id="team-A", prisma_client=prisma, + general_settings=_PROJECTS_ENABLED, ) assert has_perm is True +@pytest.mark.asyncio +async def test_project_perm_check_allows_members_with_roles_admin(): + """Team admins added through /team/member_add live in members_with_roles, not the legacy admins list.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _check_user_permission_for_project, + ) + + prisma = _make_prisma_with_team( + team_id="team-A", + admins=[], + members_with_roles=(Member(user_id="carol", role="admin"), Member(user_id="dave", role="user")), + ) + carol = UserAPIKeyAuth(user_id="carol", user_role=LitellmUserRoles.INTERNAL_USER.value) + dave = UserAPIKeyAuth(user_id="dave", user_role=LitellmUserRoles.INTERNAL_USER.value) + + assert ( + await _check_user_permission_for_project( + user_api_key_dict=carol, team_id="team-A", prisma_client=prisma, general_settings=_PROJECTS_ENABLED + ) + is True + ) + assert ( + await _check_user_permission_for_project( + user_api_key_dict=dave, team_id="team-A", prisma_client=prisma, general_settings=_PROJECTS_ENABLED + ) + is False + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("general_settings", [MappingProxyType({}), _PROJECTS_DISABLED]) +async def test_project_perm_check_denies_team_admin_unless_projects_permission_configured(general_settings): + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _check_user_permission_for_project, + ) + + prisma = _make_prisma_with_team( + team_id="team-A", admins=["alice"], members_with_roles=(Member(user_id="carol", role="admin"),) + ) + + for user_id in ("alice", "carol"): + caller = UserAPIKeyAuth(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER.value) + has_perm = await _check_user_permission_for_project( + user_api_key_dict=caller, + team_id="team-A", + prisma_client=prisma, + general_settings=general_settings, + ) + assert has_perm is False + prisma.db.litellm_teamtable.find_unique.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_perm_check_require_admin_denies_team_admin_even_when_configured(): + """/project/delete passes require_admin=True, so the projects permission must not open it up.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _check_user_permission_for_project, + ) + + prisma = _make_prisma_with_team(team_id="team-A", admins=["alice"]) + alice = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value) + + has_perm = await _check_user_permission_for_project( + user_api_key_dict=alice, + team_id=None, + prisma_client=prisma, + general_settings=_PROJECTS_ENABLED, + require_admin=True, + ) + assert has_perm is False + prisma.db.litellm_teamtable.find_unique.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_perm_check_uses_injected_team_object_for_reassignment_target(): + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _check_user_permission_for_project, + ) + + prisma = _make_prisma_with_team(team_id="team-A", admins=["alice"]) + alice = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value) + target_team = LiteLLM_TeamTable(team_id="team-B", members_with_roles=[Member(user_id="erin", role="admin")]) + + has_perm = await _check_user_permission_for_project( + user_api_key_dict=alice, + team_id="team-B", + prisma_client=prisma, + general_settings=_PROJECTS_ENABLED, + team_object=target_team, + ) + assert has_perm is False + prisma.db.litellm_teamtable.find_unique.assert_not_called() + + @pytest.mark.asyncio async def test_project_perm_check_proxy_admin_always_allowed(): from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( @@ -90,6 +190,7 @@ async def test_project_perm_check_proxy_admin_always_allowed(): user_api_key_dict=admin, team_id="team-A", prisma_client=prisma, + general_settings=MappingProxyType({}), ) assert has_perm is True # Admin shortcut should not even hit the DB. diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py index 5b31089f91e..1a72d1de393 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py @@ -9,6 +9,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import ( changed_team_fields, resolve_team_admin_editable_fields, team_admin_edit_verdict, + team_admin_may_manage_projects, team_admin_request_or_raise, ) @@ -31,6 +32,25 @@ class TestResolveTeamAdminEditableFields: def test_malformed_setting_fails_closed(self, raw): assert resolve_team_admin_editable_fields({"team_admin_editable_team_fields": raw}, _SUPPORTED) == frozenset() + def test_projects_permission_is_not_a_team_field(self): + configured = {"team_admin_editable_team_fields": ["projects", "tpm_limit"]} + assert resolve_team_admin_editable_fields(configured, _SUPPORTED) == frozenset({"tpm_limit"}) + + +class TestTeamAdminMayManageProjects: + def test_missing_setting_denies(self): + assert team_admin_may_manage_projects({}) is False + + def test_team_fields_alone_do_not_grant_projects(self): + assert team_admin_may_manage_projects({"team_admin_editable_team_fields": ["tpm_limit", "max_budget"]}) is False + + def test_projects_entry_grants(self): + assert team_admin_may_manage_projects({"team_admin_editable_team_fields": ["max_budget", "projects"]}) is True + + @pytest.mark.parametrize("raw", ["projects", 7, [1, 2]]) + def test_malformed_setting_denies(self, raw): + assert team_admin_may_manage_projects({"team_admin_editable_team_fields": raw}) is False + class TestChangedTeamFields: def test_team_id_alone_changes_nothing(self): 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 fc5733b1a82..9860d1bf94a 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 @@ -3291,7 +3291,7 @@ class TestTeamAdminEditableTeamFieldsSetting: def test_patch_rejects_field_names_the_proxy_does_not_support(self, monkeypatch): mock_prisma = self._as_proxy_admin(monkeypatch) monkeypatch.setattr( - "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS", + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_PERMISSIONS", frozenset({"tpm_limit"}), ) @@ -3336,6 +3336,26 @@ class TestTeamAdminEditableTeamFieldsSetting: assert stored["team_admin_editable_team_fields"] == enabled assert general_settings["team_admin_editable_team_fields"] == enabled + def test_patch_accepts_the_projects_permission_and_project_endpoints_see_it(self, monkeypatch): + from litellm.proxy.management_endpoints.team_admin_field_permissions import ( + team_admin_may_manage_projects, + ) + + mock_prisma = self._as_proxy_admin(monkeypatch) + general_settings: dict = {} + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + assert team_admin_may_manage_projects(general_settings) is False + + try: + response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["projects"]}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"]) + assert stored["team_admin_editable_team_fields"] == ["projects"] + assert team_admin_may_manage_projects(general_settings) is True + def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch): mock_prisma = self._as_proxy_admin(monkeypatch) general_settings: dict = {"team_admin_editable_team_fields": ["tpm_limit"]} @@ -3372,6 +3392,7 @@ class TestTeamAdminEditableTeamFieldsSetting: assert field_schema["type"] == "array" assert field_schema["items"]["type"] == "string" assert "tpm_limit" in field_schema["items"]["enum"] + assert "projects" in field_schema["items"]["enum"] class TestSyncUiSettingsToGeneralSettings: diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts index da3f9bf8289..1ef3816b5c4 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts @@ -13,6 +13,7 @@ describe("teamAdminFieldLabel", () => { ["tpm_limit", "Tokens per minute Limit (TPM)"], ["rpm_limit", "Requests per minute Limit (RPM)"], ["max_budget", "Max Budget (USD)"], + ["projects", "Create and update projects"], ])("names %s the way the team settings form does", (field, label) => { expect(teamAdminFieldLabel(field)).toBe(label); }); diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index b878af03df6..5706eeafe82 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -47,6 +47,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([ ["tpm_limit", "Tokens per minute Limit (TPM)"], ["rpm_limit", "Requests per minute Limit (RPM)"], ["max_budget", "Max Budget (USD)"], + ["projects", "Create and update projects"], ]); export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;