diff --git a/litellm/constants.py b/litellm/constants.py index dbc79b69a67..0cf9700cddd 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1417,6 +1417,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int( ) LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "default_internal_user_params", + "default_team_params", "public_mcp_servers", "public_agent_groups", "public_model_groups", diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 3049a9f43cf..1c0c212b60b 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2297,23 +2297,16 @@ async def validate_key_team_change( # Check if the team has access to the key's models if len(key.models) > 0: for model in key.models: + # Skip special sentinel values — "all-team-models" means + # "use whatever the team allows", so it's always valid. + if model == SpecialModelNames.all_team_models.value: + continue await can_team_access_model( model=model, team_object=team, llm_router=llm_router, ) - # Check if the key's user_id is a member of the team - member_object = _get_user_in_team( - team_table=cast(LiteLLM_TeamTableCachedObj, team), user_id=key.user_id - ) - if key.user_id is not None: - if not member_object: - raise HTTPException( - status_code=403, - detail=f"User={key.user_id} is not a member of the team={team.team_id}. Check team members via `/team/info`.", - ) - # Check if the key's tpm/rpm limit is less than the team's tpm/rpm limit if key.tpm_limit is not None: if team.tpm_limit and key.tpm_limit > team.tpm_limit: @@ -2327,6 +2320,17 @@ async def validate_key_team_change( detail=f"Key={key.token} has a rpm_limit={key.rpm_limit} which is greater than the team's rpm_limit={team.rpm_limit}.", ) + # Check if the key's user_id is a member of the team + member_object = _get_user_in_team( + team_table=cast(LiteLLM_TeamTableCachedObj, team), user_id=key.user_id + ) + if key.user_id is not None: + if not member_object: + raise HTTPException( + status_code=403, + detail=f"User={key.user_id} is not a member of the team={team.team_id}. Check team members via `/team/info`.", + ) + # Check if the person initiating the change is a Proxy Admin or Team Admin if change_initiated_by.user_role == LitellmUserRoles.PROXY_ADMIN.value: return diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 8e3061c2032..0e60820aab1 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -456,11 +456,8 @@ async def list_tags( dynamic_tag_rows = await prisma_client.db.litellm_dailytagspend.group_by( by=["tag"], where={"tag": {"not": None}}, - # The old find_many(distinct=...) returned arbitrary timestamps from - # whichever row Prisma happened to pick. MIN/MAX give more meaningful - # values: earliest appearance and most recent activity. - _min={"created_at": True}, - _max={"updated_at": True}, + min={"created_at": True}, + max={"updated_at": True}, ) dynamic_tag_config = [ @@ -468,8 +465,8 @@ async def list_tags( "name": row["tag"], "description": "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.", "models": None, - "created_at": row["_min"]["created_at"].isoformat(), - "updated_at": row["_max"]["updated_at"].isoformat(), + "created_at": row["_min"]["created_at"], + "updated_at": row["_max"]["updated_at"], } for row in dynamic_tag_rows if row["tag"] not in stored_tag_names diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 9c8e6f7282b..d83ceb1b095 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -252,6 +252,28 @@ class TeamMemberBudgetHandler: data_dict.pop("team_member_tpm_limit", None) +def _get_default_team_param(field: str) -> Any: + """ + Returns a default value for the given field from litellm.default_team_params config. + Returns None if no default is configured. + + For list fields containing enums (e.g. team_member_permissions), converts enum values to strings. + """ + default_params = litellm.default_team_params + if default_params is None: + return None + if isinstance(default_params, dict): + value = default_params.get(field) + else: + value = getattr(default_params, field, None) + if value is None: + return None + # Convert enum values in lists to strings + if isinstance(value, list): + return [v.value if hasattr(v, "value") else v for v in value] + return value + + def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool: if litellm.default_internal_user_params is None: return False @@ -833,16 +855,23 @@ async def new_team( # noqa: PLR0915 prisma_client=prisma_client, ) - # If max_budget is not explicitly provided in the request, - # check for a default value in the proxy configuration. + # Apply defaults from litellm.default_team_params for any fields + # not explicitly provided in the request. + for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"): + if getattr(data, field, None) is None: + default_value = _get_default_team_param(field) + if default_value is not None: + setattr(data, field, default_value) + + # Legacy fallback: apply max_budget from default_team_settings (YAML config) + # if still not set after checking default_team_params. if data.max_budget is None: if ( isinstance(litellm.default_team_settings, list) and len(litellm.default_team_settings) > 0 and isinstance(litellm.default_team_settings[0], dict) ): - default_settings = litellm.default_team_settings[0] - default_budget = default_settings.get("max_budget") + default_budget = litellm.default_team_settings[0].get("max_budget") if default_budget is not None: data.max_budget = default_budget diff --git a/litellm/proxy/management_helpers/team_member_permission_checks.py b/litellm/proxy/management_helpers/team_member_permission_checks.py index 4642028b77c..7dd99d4ff18 100644 --- a/litellm/proxy/management_helpers/team_member_permission_checks.py +++ b/litellm/proxy/management_helpers/team_member_permission_checks.py @@ -16,11 +16,13 @@ from litellm.proxy.auth.auth_checks import get_team_object from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.utils import PrismaClient -DEFAULT_TEAM_MEMBER_PERMISSIONS = [ +BASELINE_TEAM_MEMBER_PERMISSIONS = [ KeyManagementRoutes.KEY_INFO, KeyManagementRoutes.KEY_HEALTH, ] +DEFAULT_TEAM_MEMBER_PERMISSIONS = BASELINE_TEAM_MEMBER_PERMISSIONS + class TeamMemberPermissionChecks: @staticmethod @@ -29,15 +31,23 @@ class TeamMemberPermissionChecks: team_table: LiteLLM_TeamTableCachedObj, ) -> List[KeyManagementRoutes]: """ - Returns the permissions for a team member + Returns the permissions for a team member. + + - If team has explicit permissions set (including []), use those + plus baseline permissions (/key/info, /key/health). + - If team has no permissions set (None), fall back to + DEFAULT_TEAM_MEMBER_PERMISSIONS. """ - if team_table.team_member_permissions and isinstance( + if team_table.team_member_permissions is not None and isinstance( team_table.team_member_permissions, list ): - return [ + permissions = { KeyManagementRoutes(permission) for permission in team_table.team_member_permissions - ] + } + # Always include baseline permissions + permissions.update(BASELINE_TEAM_MEMBER_PERMISSIONS) + return list(permissions) return DEFAULT_TEAM_MEMBER_PERMISSIONS diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 8df215d9980..0fa27905bab 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -328,11 +328,34 @@ async def _get_settings_with_schema( } # Add property descriptions + defs = schema.get("$defs", schema.get("definitions", {})) for field_name, field_info in schema["properties"].items(): - result["field_schema"]["properties"][field_name] = { + # For Optional fields, Pydantic v2 uses anyOf with [actual_type, null]. + # Resolve the non-null variant to get the real type and items. + resolved = field_info + if "anyOf" in field_info: + for variant in field_info["anyOf"]: + if variant.get("type") != "null": + resolved = variant + break + + prop_entry: dict = { "description": field_info.get("description", ""), - "type": field_info.get("type", "string"), + "type": resolved.get("type", "string"), } + # Pass through items info (including enum values) for array fields + # so the UI can render a multi-select dropdown + if "items" in resolved: + items = resolved["items"] + # Resolve $ref to enum definitions if needed + if "$ref" in items: + ref_name = items["$ref"].split("/")[-1] + ref_def = defs.get(ref_name, {}) + if "enum" in ref_def: + prop_entry["items"] = {"enum": ref_def["enum"]} + else: + prop_entry["items"] = items + result["field_schema"]["properties"][field_name] = prop_entry # Add nested object descriptions for def_name, def_schema in schema.get("definitions", {}).items(): @@ -427,7 +450,6 @@ async def _update_litellm_setting( DefaultInternalUserParams, DefaultTeamSSOParams, MCPSemanticFilterSettings ], settings_key: str, - in_memory_var: Any, success_message: str, ): """ @@ -436,7 +458,6 @@ async def _update_litellm_setting( Args: settings: The settings object to update settings_key: The key in litellm_settings to update - in_memory_var: The in-memory variable to update success_message: Message to return on success """ from litellm.proxy.proxy_server import proxy_config, store_model_in_db @@ -449,13 +470,16 @@ async def _update_litellm_setting( }, ) - # Update the in-memory settings in_memory_var = settings.model_dump(exclude_none=True) - setattr(litellm, settings_key, in_memory_var) - # Load existing config + # Load existing config first, then set in-memory value after, + # because get_config() may overwrite litellm. with stale DB values + # via LITELLM_SETTINGS_SAFE_DB_OVERRIDES. config = await proxy_config.get_config() + # Update the in-memory settings (after get_config to avoid stale override) + setattr(litellm, settings_key, in_memory_var) + # Update config with new settings if "litellm_settings" not in config: config["litellm_settings"] = {} @@ -495,7 +519,6 @@ async def update_internal_user_settings( return await _update_litellm_setting( settings=settings, settings_key="default_internal_user_params", - in_memory_var=litellm.default_internal_user_params, success_message="Internal user settings updated successfully", ) @@ -513,7 +536,6 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams): return await _update_litellm_setting( settings=settings, settings_key="default_team_params", - in_memory_var=litellm.default_team_params, success_message="Default team settings updated successfully", ) @@ -935,7 +957,6 @@ async def update_mcp_semantic_filter_settings( result = await _update_litellm_setting( settings=settings, settings_key="mcp_semantic_tool_filter", - in_memory_var=None, success_message="MCP Semantic Filter settings updated successfully. Changes will be applied across all pods within 10 seconds.", ) try: diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 6743c4a5b9b..7d8ff0f65c1 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -3,7 +3,7 @@ from typing import Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field from typing_extensions import TypedDict -from litellm.proxy._types import LitellmUserRoles +from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles from litellm.types.utils import LiteLLMPydanticObjectBase @@ -205,6 +205,10 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): default=None, description="Default rpm limit for new automatically created teams", ) + team_member_permissions: Optional[List[KeyManagementRoutes]] = Field( + default=None, + description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.", + ) class InProductNudgeResponse(BaseModel): diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 1355ca0abbe..3c4444a5efc 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -202,7 +202,6 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp await _update_litellm_setting( settings=settings, settings_key="default_internal_user_params", - in_memory_var=litellm.default_internal_user_params, success_message="ok", ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index e496cf373ea..cfc16808afb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1502,6 +1502,57 @@ async def test_validate_key_team_change_with_member_permissions(): ) +@pytest.mark.asyncio +async def test_validate_key_team_change_skips_all_team_models_sentinel(): + """ + Test that validate_key_team_change skips the 'all-team-models' sentinel + value when checking if the target team can access the key's models. + + Keys with models=["all-team-models"] mean "use whatever models the team + allows", so moving them to any team should not fail model validation. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + mock_key = MagicMock() + mock_key.user_id = "test-user-123" + mock_key.models = ["all-team-models"] + mock_key.tpm_limit = None + mock_key.rpm_limit = None + + mock_team = MagicMock() + mock_team.team_id = "test-team-456" + mock_team.models = ["gpt-4", "claude-3"] + mock_team.members_with_roles = [] + mock_team.tpm_limit = None + mock_team.rpm_limit = None + + mock_change_initiator = MagicMock() + mock_change_initiator.user_id = "test-user-123" + mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value + + mock_router = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", + new_callable=AsyncMock, + ) as mock_can_access: + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" + ) as mock_get_user: + mock_get_user.return_value = MagicMock() + + await validate_key_team_change( + key=mock_key, + team=mock_team, + change_initiated_by=mock_change_initiator, + llm_router=mock_router, + ) + + # can_team_access_model should NOT have been called since + # "all-team-models" is a sentinel that should be skipped + mock_can_access.assert_not_called() + + def test_key_rotation_fields_helper(): """ Test the key data update logic for rotation fields. diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index f330b40282c..4b443f211ff 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -282,9 +282,9 @@ async def test_list_tags_with_dynamic_tags(): # Setup dynamic tags via group_by — includes one that overlaps with stored mock_db.litellm_dailytagspend.group_by = AsyncMock(return_value=[ - {"tag": "dynamic-tag-1", "_min": {"created_at": datetime(2025, 2, 1)}, "_max": {"updated_at": datetime(2025, 3, 1)}}, - {"tag": "dynamic-tag-2", "_min": {"created_at": datetime(2025, 2, 2)}, "_max": {"updated_at": datetime(2025, 3, 2)}}, - {"tag": "stored-tag", "_min": {"created_at": datetime(2025, 1, 1)}, "_max": {"updated_at": datetime(2025, 1, 1)}}, # duplicate, should be excluded + {"tag": "dynamic-tag-1", "_min": {"created_at": "2025-02-01T00:00:00Z"}, "_max": {"updated_at": "2025-03-01T00:00:00Z"}}, + {"tag": "dynamic-tag-2", "_min": {"created_at": "2025-02-02T00:00:00Z"}, "_max": {"updated_at": "2025-03-02T00:00:00Z"}}, + {"tag": "stored-tag", "_min": {"created_at": "2025-01-01T00:00:00Z"}, "_max": {"updated_at": "2025-01-01T00:00:00Z"}}, # duplicate, should be excluded ]) headers = {"Authorization": "Bearer sk-1234"} diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py new file mode 100644 index 00000000000..7fc7cb8aae2 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -0,0 +1,487 @@ +""" +Tests for applying default team params during team creation +and loading default_team_params from DB on startup. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../") +) # Adds the parent directory to the system path + +import litellm +from litellm.proxy._types import ( + NewTeamRequest, + UserAPIKeyAuth, + LitellmUserRoles, +) +from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, +) +from litellm.proxy.proxy_server import ProxyConfig + + +# --------------------------------------------------------------------------- +# _update_config_fields: default_team_params loaded from DB on startup +# --------------------------------------------------------------------------- + + +class TestConfigFieldsDefaultTeamParams: + """Tests that _update_config_fields applies default_team_params from DB.""" + + def _make_proxy_config(self) -> ProxyConfig: + return ProxyConfig() + + def test_default_team_params_applied_from_db(self, monkeypatch): + """default_team_params in DB is set on litellm module during config load.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + db_settings = { + "default_team_params": { + "max_budget": 500.0, + "budget_duration": "30d", + "tpm_limit": 1000, + "rpm_limit": 200, + "team_member_permissions": ["/key/generate", "/key/delete"], + } + } + + pc._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + assert litellm.default_team_params == db_settings["default_team_params"] + + def test_default_team_params_merged_into_config_dict(self): + """DB default_team_params ends up in the returned config dict.""" + pc = self._make_proxy_config() + config = {"litellm_settings": {"cache": False}} + db_settings = { + "default_team_params": { + "max_budget": 100.0, + } + } + + result = pc._update_config_fields( + current_config=config, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0} + # Existing keys preserved + assert result["litellm_settings"]["cache"] is False + + def test_default_team_params_not_applied_when_absent(self, monkeypatch): + """When DB litellm_settings has no default_team_params, it stays None.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + pc._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value={"cache": True}, + ) + + assert litellm.default_team_params is None + + def test_default_team_params_overrides_yaml_value(self, monkeypatch): + """DB value for default_team_params overrides YAML value via deep merge.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + config = { + "litellm_settings": { + "default_team_params": { + "max_budget": 50.0, + "tpm_limit": 100, + } + } + } + db_settings = { + "default_team_params": { + "max_budget": 200.0, + "rpm_limit": 500, + } + } + + result = pc._update_config_fields( + current_config=config, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + merged = result["litellm_settings"]["default_team_params"] + # DB value wins for max_budget + assert merged["max_budget"] == 200.0 + # DB adds rpm_limit + assert merged["rpm_limit"] == 500 + # YAML tpm_limit preserved (not in DB) + assert merged["tpm_limit"] == 100 + + # setattr should have applied the DB value + assert litellm.default_team_params == db_settings["default_team_params"] + + +# --------------------------------------------------------------------------- +# new_team: default params applied to team creation +# +# We test the defaults-application logic by calling new_team with +# prisma_client patched at the proxy_server module level (where the +# endpoint imports it from). +# --------------------------------------------------------------------------- + + +class TestNewTeamDefaultParamsApplied: + """Tests that /team/new applies defaults from litellm.default_team_params.""" + + @pytest.fixture(autouse=True) + def setup_mocks(self, monkeypatch): + """Set up common mocks for team creation tests.""" + mock_prisma = AsyncMock() + mock_prisma.insert_data = AsyncMock( + return_value=MagicMock( + team_id="test-team-id", + team_alias="test-team", + ) + ) + mock_prisma.get_generic_data = AsyncMock(return_value=None) + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_teamtable = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ) + + # Reset default_team_settings to avoid legacy fallback interference + monkeypatch.setattr(litellm, "default_team_settings", None) + + def _make_admin_auth(self) -> UserAPIKeyAuth: + return UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + @pytest.mark.asyncio + async def test_all_defaults_applied_when_not_provided(self, monkeypatch): + """When no budget/rate/permission fields are in the request, all defaults apply.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + "team_member_permissions": ["/key/generate", "/key/update"], + }, + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass # May fail on downstream mocks, that's OK + + # Verify defaults were set on the data object + assert data.max_budget == 100.0 + assert data.budget_duration == "30d" + assert data.tpm_limit == 200 + assert data.rpm_limit == 500 + assert data.team_member_permissions == ["/key/generate", "/key/update"] + + @pytest.mark.asyncio + async def test_explicit_values_not_overridden(self, monkeypatch): + """When request provides explicit values, defaults do not override them.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + "team_member_permissions": ["/key/generate"], + }, + ) + + data = NewTeamRequest( + team_alias="my-team", + max_budget=50.0, + budget_duration="7d", + tpm_limit=999, + rpm_limit=888, + team_member_permissions=["/key/delete"], + ) + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + # Explicit values preserved + assert data.max_budget == 50.0 + assert data.budget_duration == "7d" + assert data.tpm_limit == 999 + assert data.rpm_limit == 888 + assert data.team_member_permissions == ["/key/delete"] + + @pytest.mark.asyncio + async def test_partial_defaults_applied(self, monkeypatch): + """Only missing fields get defaults; provided fields are untouched.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + }, + ) + + data = NewTeamRequest( + team_alias="my-team", + max_budget=75.0, # explicit + # budget_duration, tpm_limit, rpm_limit not set → defaults apply + ) + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget == 75.0 # explicit, not overridden + assert data.budget_duration == "30d" # default applied + assert data.tpm_limit == 200 # default applied + assert data.rpm_limit == 500 # default applied + + @pytest.mark.asyncio + async def test_no_defaults_when_config_is_none(self, monkeypatch): + """When default_team_params is None, no defaults applied.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", None) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget is None + assert data.budget_duration is None + assert data.tpm_limit is None + assert data.rpm_limit is None + assert data.team_member_permissions is None + + @pytest.mark.asyncio + async def test_legacy_default_team_settings_fallback(self, monkeypatch): + """Legacy default_team_settings YAML config applies max_budget as fallback.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", None) + monkeypatch.setattr( + litellm, + "default_team_settings", + [{"team_id": "default", "max_budget": 999.0}], + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget == 999.0 + + @pytest.mark.asyncio + async def test_default_team_params_takes_priority_over_legacy(self, monkeypatch): + """default_team_params max_budget takes priority over legacy default_team_settings.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + {"max_budget": 100.0}, + ) + monkeypatch.setattr( + litellm, + "default_team_settings", + [{"team_id": "default", "max_budget": 999.0}], + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + # default_team_params wins (100.0), legacy fallback (999.0) not used + assert data.max_budget == 100.0 + + +# --------------------------------------------------------------------------- +# _update_litellm_setting: setattr ordering +# --------------------------------------------------------------------------- + + +class TestUpdateLitellmSettingOrdering: + """Tests that _update_litellm_setting sets in-memory value AFTER get_config, + so stale DB values from LITELLM_SETTINGS_SAFE_DB_OVERRIDES don't overwrite it.""" + + @pytest.mark.asyncio + async def test_setattr_not_overwritten_by_get_config(self, monkeypatch): + """The new in-memory value survives get_config() which may load stale DB values.""" + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + _update_litellm_setting, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + # Simulate stale DB state: get_config returns old default_team_params + stale_value = {"max_budget": 50.0} + monkeypatch.setattr(litellm, "default_team_params", stale_value) + + # get_config will overwrite litellm.default_team_params with stale DB value + async def mock_get_config(): + # Simulate what _update_config_from_db does for safe overrides + litellm.default_team_params = stale_value + return { + "litellm_settings": { + "default_team_params": stale_value, + } + } + + saved_configs = [] + + async def mock_save_config(new_config=None): + saved_configs.append(new_config) + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + monkeypatch.setattr(proxy_config, "save_config", mock_save_config) + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", True + ) + + # New settings to save + new_settings = DefaultTeamSSOParams( + max_budget=200.0, + budget_duration="7d", + rpm_limit=1000, + ) + + result = await _update_litellm_setting( + settings=new_settings, + settings_key="default_team_params", + success_message="Updated", + ) + + # In-memory value should be the NEW value, not the stale one + expected = new_settings.model_dump(exclude_none=True) + assert litellm.default_team_params == expected + + # Saved config should contain the new value + assert len(saved_configs) == 1 + saved_settings = saved_configs[0]["litellm_settings"]["default_team_params"] + assert saved_settings == expected + + # Return value should reflect the new settings + assert result["settings"] == expected + + @pytest.mark.asyncio + async def test_requires_store_model_in_db(self, monkeypatch): + """Raises HTTPException when store_model_in_db is not True.""" + from fastapi import HTTPException + + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + _update_litellm_setting, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", False + ) + + with pytest.raises(HTTPException) as exc_info: + await _update_litellm_setting( + settings=DefaultTeamSSOParams(max_budget=100.0), + settings_key="default_team_params", + success_message="Updated", + ) + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# LITELLM_SETTINGS_SAFE_DB_OVERRIDES contains default_team_params +# --------------------------------------------------------------------------- + + +class TestSafeDbOverrides: + """Verify default_team_params is in the safe overrides list.""" + + def test_default_team_params_in_safe_overrides(self): + from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + assert "default_team_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + def test_default_internal_user_params_in_safe_overrides(self): + """Sanity: default_internal_user_params was already in the list.""" + from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + assert "default_internal_user_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py new file mode 100644 index 00000000000..6aa08dddd08 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -0,0 +1,190 @@ +import os +import sys +from unittest.mock import MagicMock + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy._types import KeyManagementRoutes, Member +from litellm.proxy.management_helpers.team_member_permission_checks import ( + BASELINE_TEAM_MEMBER_PERMISSIONS, + TeamMemberPermissionChecks, +) + + +def _make_team_table(team_member_permissions): + """Create a mock team table object with given permissions.""" + team = MagicMock() + team.team_member_permissions = team_member_permissions + return team + + +class TestGetPermissionsForTeamMember: + def test_none_permissions_returns_defaults(self): + """When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS.""" + team = _make_team_table(None) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert set(result) == set(BASELINE_TEAM_MEMBER_PERMISSIONS) + + def test_empty_list_includes_baseline(self): + """When team_member_permissions is [], baseline permissions are still included.""" + team = _make_team_table([]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_HEALTH in result + + def test_explicit_permissions_include_baseline(self): + """When explicit permissions are set, baseline is always included.""" + team = _make_team_table(["/key/generate", "/key/delete"]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert KeyManagementRoutes.KEY_GENERATE in result + assert KeyManagementRoutes.KEY_DELETE in result + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_HEALTH in result + + def test_explicit_permissions_with_baseline_no_duplicates(self): + """When explicit permissions already include baseline, no duplicates.""" + team = _make_team_table(["/key/info", "/key/generate"]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + # Using set ensures no duplicates from the implementation + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_GENERATE in result + assert KeyManagementRoutes.KEY_HEALTH in result + + +class TestGetDefaultTeamParam: + def test_returns_none_when_no_config(self, monkeypatch): + """Returns None when litellm.default_team_params is None.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr(litellm, "default_team_params", None) + + assert _get_default_team_param("team_member_permissions") is None + assert _get_default_team_param("max_budget") is None + + def test_returns_none_when_field_not_set(self, monkeypatch): + """Returns None when default_team_params exists but the field is not set.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr(litellm, "default_team_params", {"models": ["gpt-4"]}) + + assert _get_default_team_param("team_member_permissions") is None + assert _get_default_team_param("max_budget") is None + + def test_returns_permissions_from_dict_config(self, monkeypatch): + """Returns permissions when default_team_params is a dict.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr( + litellm, + "default_team_params", + {"team_member_permissions": ["/key/generate", "/key/update"]}, + ) + + result = _get_default_team_param("team_member_permissions") + assert result == ["/key/generate", "/key/update"] + + def test_returns_scalar_fields_from_dict_config(self, monkeypatch): + """Returns scalar fields (max_budget, tpm_limit, etc.) from dict config.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + }, + ) + + assert _get_default_team_param("max_budget") == 100.0 + assert _get_default_team_param("budget_duration") == "30d" + assert _get_default_team_param("tpm_limit") == 200 + assert _get_default_team_param("rpm_limit") == 500 + + def test_returns_permissions_from_pydantic_config(self, monkeypatch): + """Returns permissions when default_team_params is a DefaultTeamSSOParams object.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + params = DefaultTeamSSOParams( + team_member_permissions=[ + KeyManagementRoutes.KEY_GENERATE, + KeyManagementRoutes.KEY_DELETE, + ] + ) + monkeypatch.setattr(litellm, "default_team_params", params) + + result = _get_default_team_param("team_member_permissions") + assert result == ["/key/generate", "/key/delete"] + + def test_returns_scalar_fields_from_pydantic_config(self, monkeypatch): + """Returns scalar fields from DefaultTeamSSOParams object.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + params = DefaultTeamSSOParams( + max_budget=250.0, + budget_duration="7d", + tpm_limit=1000, + rpm_limit=100, + ) + monkeypatch.setattr(litellm, "default_team_params", params) + + assert _get_default_team_param("max_budget") == 250.0 + assert _get_default_team_param("budget_duration") == "7d" + assert _get_default_team_param("tpm_limit") == 1000 + assert _get_default_team_param("rpm_limit") == 100 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 f955a6134bf..7378b14cdf5 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 @@ -231,6 +231,56 @@ class TestProxySettingEndpoints: # Verify save_config was called exactly once assert mock_proxy_config["save_call_count"]() == 1 + def test_get_default_team_settings_includes_team_member_permissions_schema( + self, mock_proxy_config, mock_auth + ): + """Test that team_member_permissions field appears in schema with enum items""" + response = client.get("/get/default_team_settings") + + assert response.status_code == 200 + data = response.json() + + # Check that team_member_permissions is in the schema + props = data["field_schema"]["properties"] + assert "team_member_permissions" in props + + perm_schema = props["team_member_permissions"] + assert perm_schema["type"] == "array" + assert "items" in perm_schema + assert "enum" in perm_schema["items"] + # Verify some known enum values are present + enum_values = perm_schema["items"]["enum"] + assert "/key/generate" in enum_values + assert "/key/info" in enum_values + assert "/key/delete" in enum_values + + def test_update_default_team_settings_with_permissions( + self, mock_proxy_config, mock_auth, monkeypatch + ): + """Test updating default team settings with team_member_permissions""" + import litellm + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr(litellm, "default_team_params", {}) + + new_settings = { + "models": ["gpt-4"], + "team_member_permissions": ["/key/generate", "/key/update", "/key/delete"], + } + + response = client.patch("/update/default_team_settings", json=new_settings) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + settings = data["settings"] + assert settings["team_member_permissions"] == [ + "/key/generate", + "/key/update", + "/key/delete", + ] + def test_get_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch): """Test getting the SSO settings from the dedicated database table""" from unittest.mock import AsyncMock, MagicMock diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx new file mode 100644 index 00000000000..848807ce5a0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { ScoreChart } from "./ScoreChart"; + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => ( +
+ {data.map((d, i) => ( + + {d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")} + + ))} +
+ ), + }; +}); + +describe("ScoreChart", () => { + it("should render the title", () => { + renderWithProviders(); + + expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); + }); + + it("should show empty state when no data is provided", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should show empty state when data is an empty array", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should render the chart when data is provided", () => { + const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, + ]; + + renderWithProviders(); + + expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); + expect(screen.getByText(/2026-03-01/)).toBeInTheDocument(); + expect(screen.getByText(/2026-03-02/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/HelpLink.test.tsx b/ui/litellm-dashboard/src/components/HelpLink.test.tsx new file mode 100644 index 00000000000..72033bd93a1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/HelpLink.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../tests/test-utils"; +import { HelpLink, HelpIcon, DocsMenu } from "./HelpLink"; + +describe("HelpLink", () => { + it("should render with default children and open in new tab", () => { + renderWithProviders(); + + const link = screen.getByRole("link", { name: /learn more/i }); + expect(link).toHaveAttribute("href", "https://docs.example.com"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render custom children text", () => { + renderWithProviders( + Custom docs link + ); + + expect(screen.getByText("Custom docs link")).toBeInTheDocument(); + }); + + it("should include a screen-reader-only label for accessibility", () => { + renderWithProviders(); + + expect(screen.getByText("(opens in a new tab)")).toBeInTheDocument(); + }); +}); + +describe("HelpIcon", () => { + it("should render a help button with accessible label", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /help information/i })).toBeInTheDocument(); + }); + + it("should show tooltip content on hover", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + expect(screen.getByText("Tooltip help text")).toBeInTheDocument(); + }); + + it("should show learn more link when learnMoreHref is provided", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + const link = screen.getByRole("link", { name: /read docs/i }); + expect(link).toHaveAttribute("href", "https://docs.example.com"); + }); + + it("should not show learn more link when learnMoreHref is not provided", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); +}); + +describe("DocsMenu", () => { + const items = [ + { label: "Custom pricing", href: "https://docs.example.com/pricing" }, + { label: "Cost tracking", href: "https://docs.example.com/cost" }, + ]; + + it("should render the menu button with default text", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument(); + }); + + it("should show menu items when button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + + expect(screen.getByText("Custom pricing")).toBeInTheDocument(); + expect(screen.getByText("Cost tracking")).toBeInTheDocument(); + }); + + it("should close the menu when an item is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + await user.click(screen.getByText("Custom pricing")); + + expect(screen.queryByText("Cost tracking")).not.toBeInTheDocument(); + }); + + it("should set aria-expanded correctly based on menu state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const button = screen.getByRole("button", { name: /docs/i }); + expect(button).toHaveAttribute("aria-expanded", "false"); + + await user.click(button); + expect(button).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index ae93b118799..5006afb61e2 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => { aria-label="Budget duration" > - - + + + ); BudgetDurationDropdown.displayName = "BudgetDurationDropdown"; return { default: BudgetDurationDropdown, - getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`), + getBudgetDurationLabel: vi.fn((value: string) => { + const map: Record = { "24h": "daily", "7d": "weekly", "30d": "monthly" }; + return map[value] || value; + }), }; }); @@ -56,6 +60,7 @@ vi.mock("./ModelSelect/ModelSelect", () => { vi.mock("antd", async (importOriginal) => { const actual = await importOriginal(); const React = await import("react"); + const SelectComponent = ({ value, onChange, @@ -88,37 +93,53 @@ vi.mock("antd", async (importOriginal) => { ); }; SelectComponent.displayName = "Select"; + const SelectOption = ({ value: optionValue, children: optionChildren }: { value: string; children: React.ReactNode }) => React.createElement("option", { value: optionValue }, optionChildren); SelectOption.displayName = "SelectOption"; SelectComponent.Option = SelectOption; - const Spin = ({ size }: { size?: string }) => React.createElement("div", { "data-testid": "spinner", "data-size": size }); + + const Spin = ({ size }: { size?: string }) => + React.createElement("div", { "data-testid": "spinner", "data-size": size }); Spin.displayName = "Spin"; - const Switch = ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) => + + const InputNumber = ({ + value, + onChange, + placeholder, + prefix, + }: { + value: number | null; + onChange: (value: number | null) => void; + placeholder?: string; + prefix?: string; + min?: number; + className?: string; + style?: React.CSSProperties; + }) => React.createElement("input", { - type: "checkbox", - role: "switch", - checked: checked, - onChange: (e) => onChange(e.target.checked), - "aria-label": "Toggle switch", + type: "number", + value: value ?? "", + onChange: (e: React.ChangeEvent) => { + const v = e.target.value === "" ? null : Number(e.target.value); + onChange(v); + }, + placeholder, + "data-prefix": prefix, + "aria-label": "number input", }); - Switch.displayName = "Switch"; - const Paragraph = ({ children }: { children: React.ReactNode }) => React.createElement("p", {}, children); - Paragraph.displayName = "Paragraph"; + InputNumber.displayName = "InputNumber"; + return { ...actual, Spin, - Switch, Select: SelectComponent, - Typography: { - Paragraph, - }, + InputNumber, }; }); const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings); const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings); -const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall); const mockNotificationsManager = vi.mocked(NotificationsManager); describe("TeamSSOSettings", () => { @@ -128,77 +149,33 @@ describe("TeamSSOSettings", () => { userRole: "admin", }; - const mockSettings = { + const mockSettingsResponse = { values: { - budget_duration: "monthly", max_budget: 1000, - enabled: true, - allowed_models: ["gpt-4", "claude-3"], + budget_duration: "30d", + tpm_limit: 500, + rpm_limit: 100, models: ["gpt-4"], - status: "active", - }, - field_schema: { - description: "Default team settings schema", - properties: { - budget_duration: { - type: "string", - description: "Budget duration setting", - }, - max_budget: { - type: "number", - description: "Maximum budget amount", - }, - enabled: { - type: "boolean", - description: "Enable feature", - }, - allowed_models: { - type: "array", - items: { - enum: ["gpt-4", "claude-3", "gpt-3.5-turbo"], - }, - description: "Allowed models", - }, - models: { - type: "array", - description: "Selected models", - }, - status: { - type: "string", - enum: ["active", "inactive", "pending"], - description: "Status", - }, - }, + team_member_permissions: ["/key/generate", "/key/update"], }, }; beforeEach(() => { vi.clearAllMocks(); - mockModelAvailableCall.mockResolvedValue({ - data: [{ id: "gpt-4" }, { id: "claude-3" }], - }); }); - it("should render", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); - }); - }); + // --- Loading & Error States --- it("should show loading spinner while fetching settings", () => { - mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { })); + mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => {})); renderWithProviders(); expect(screen.getByTestId("spinner")).toBeInTheDocument(); }); - it("should display message when no settings are available", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(null as any); + it("should display error message when fetch fails", async () => { + mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed")); renderWithProviders(); @@ -207,6 +184,7 @@ describe("TeamSSOSettings", () => { screen.getByText("No team settings available or you do not have permission to view them."), ).toBeInTheDocument(); }); + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings"); }); it("should not fetch settings when access token is null", async () => { @@ -217,432 +195,273 @@ describe("TeamSSOSettings", () => { }); }); - it("should display settings fields with correct values", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- View Mode --- + + it("should render title and subtitle", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + expect(screen.getByText("These settings will be applied by default when creating new teams.")).toBeInTheDocument(); + }); + }); + + it("should render section headers", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget & Rate Limits")).toBeInTheDocument(); + expect(screen.getByText("Access & Permissions")).toBeInTheDocument(); + }); + }); + + it("should display all field labels and descriptions", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Budget Duration")).toBeInTheDocument(); expect(screen.getByText("Max Budget")).toBeInTheDocument(); + expect(screen.getByText("Budget Duration")).toBeInTheDocument(); + expect(screen.getByText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByText("RPM Limit")).toBeInTheDocument(); + expect(screen.getByText("Models")).toBeInTheDocument(); + expect(screen.getByText("Team Member Permissions")).toBeInTheDocument(); }); - expect(screen.getByText("Budget: monthly")).toBeInTheDocument(); - expect(screen.getByText("1000")).toBeInTheDocument(); - const enabledTexts = screen.getAllByText("Enabled"); - expect(enabledTexts.length).toBeGreaterThan(0); + // Descriptions + expect(screen.getByText("Maximum budget (in USD) for new automatically created teams.")).toBeInTheDocument(); + expect(screen.getByText("How frequently the team's budget resets.")).toBeInTheDocument(); + }); + + it("should display formatted values in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + // max_budget displayed with $ + expect(screen.getByText("$1,000")).toBeInTheDocument(); + // budget_duration through getBudgetDurationLabel + expect(screen.getByText("monthly")).toBeInTheDocument(); + // tpm_limit formatted + expect(screen.getByText("500")).toBeInTheDocument(); + // rpm_limit formatted + expect(screen.getByText("100")).toBeInTheDocument(); + }); + }); + + it("should display models as tags in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + }); + + it("should display permissions as tags in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("/key/generate")).toBeInTheDocument(); + expect(screen.getByText("/key/update")).toBeInTheDocument(); + }); }); it("should display 'Not set' for null values", async () => { - const settingsWithNulls = { - ...mockSettings, + mockGetDefaultTeamSettings.mockResolvedValue({ values: { - ...mockSettings.values, max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + models: [], + team_member_permissions: [], }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithNulls); + }); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Not set")).toBeInTheDocument(); + const notSetElements = screen.getAllByText("Not set"); + // max_budget, budget_duration, tpm_limit, rpm_limit, models (empty), permissions (empty) + expect(notSetElements.length).toBeGreaterThanOrEqual(4); }); }); - it("should toggle edit mode when edit button is clicked", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- Edit Mode Toggle --- + + it("should toggle to edit mode when Edit Settings is clicked", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); - expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Save Changes/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Edit Settings/i })).not.toBeInTheDocument(); }); it("should cancel edit mode and reset values", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Cancel/i })); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await userEvent.click(cancelButton); - - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Cancel/i })).not.toBeInTheDocument(); }); - it("should save settings when save button is clicked", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockUpdateDefaultTeamSettings.mockResolvedValue({ - settings: mockSettings.values, - }); + // --- Edit Mode Fields --- + + it("should show budget duration dropdown in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", mockSettings.values); - }); - - expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully"); - }); - - it("should show error notification when save fails", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed")); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings"); + expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument(); }); }); - it("should render boolean field as switch in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + it("should show ModelSelect in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const switchElement = screen.getByRole("switch"); - expect(switchElement).toBeInTheDocument(); - expect(switchElement).toBeChecked(); - }); - }); - - it("should update boolean value when switch is toggled", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByRole("switch")).toBeInTheDocument(); - }); - - const switchElement = screen.getByRole("switch"); - await userEvent.click(switchElement); - - expect(switchElement).not.toBeChecked(); - }); - - it("should render budget duration dropdown in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); - }); - }); - - it("should update budget duration when dropdown value changes", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); - }); - - const dropdown = screen.getByLabelText("Budget duration"); - await userEvent.selectOptions(dropdown, "daily"); - - expect(dropdown).toHaveValue("daily"); - }); - - it("should render text input for string fields in edit mode", async () => { - const settingsWithString = { - ...mockSettings, - field_schema: { - ...mockSettings.field_schema, - properties: { - ...mockSettings.field_schema.properties, - team_name: { - type: "string", - description: "Team name", - }, - }, - }, - values: { - ...mockSettings.values, - team_name: "Test Team", - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const textInput = screen.getByDisplayValue("Test Team"); - expect(textInput).toBeInTheDocument(); - }); - }); - - it("should render enum select for string enum fields in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const statusSelect = screen.getAllByRole("listbox")[0]; - expect(statusSelect).toBeInTheDocument(); - }); - }); - - it("should render multi-select for array enum fields in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const multiSelects = screen.getAllByRole("listbox"); - expect(multiSelects.length).toBeGreaterThan(0); - }); - }); - - it("should render ModelSelect for models field in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); await waitFor(() => { expect(screen.getByTestId("model-select")).toBeInTheDocument(); }); }); - it("should display models as badges in view mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + it("should show number inputs for budget and rate limits in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - const gpt4Elements = screen.getAllByText("gpt-4"); - expect(gpt4Elements.length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + + await waitFor(() => { + const numberInputs = screen.getAllByLabelText("number input"); + // max_budget, tpm_limit, rpm_limit + expect(numberInputs.length).toBe(3); }); }); - it("should display 'None' for empty arrays in view mode", async () => { - const settingsWithEmptyArray = { - ...mockSettings, - values: { - ...mockSettings.values, - models: [], - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithEmptyArray); + it("should show permissions multi-select in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - const noneTexts = screen.getAllByText("None"); - expect(noneTexts.length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + + await waitFor(() => { + const listboxes = screen.getAllByRole("listbox"); + expect(listboxes.length).toBeGreaterThan(0); }); }); - it("should display schema description when available", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- Save --- + + it("should save settings and show success notification", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + mockUpdateDefaultTeamSettings.mockResolvedValue({ + settings: mockSettingsResponse.values, + }); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Default team settings schema")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); + + await waitFor(() => { + expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", expect.any(Object)); + }); + + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully"); + + // Should exit edit mode after save + await waitFor(() => { + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); }); - it("should show error notification when fetching settings fails", async () => { - mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed")); + it("should show error notification when save fails", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed")); renderWithProviders(); await waitFor(() => { - expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings"); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - }); - it("should handle model fetch error gracefully", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockModelAvailableCall.mockRejectedValue(new Error("Model fetch failed")); - - renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); await waitFor(() => { - expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings"); }); }); it("should disable cancel button while saving", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); mockUpdateDefaultTeamSettings.mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettings.values }), 100)), + () => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettingsResponse.values }), 100)), ); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - expect(cancelButton).toBeDisabled(); - }); - - it("should display field descriptions", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Budget duration setting")).toBeInTheDocument(); - expect(screen.getByText("Maximum budget amount")).toBeInTheDocument(); - }); - }); - - it("should format field names by replacing underscores and capitalizing", async () => { - const settingsWithUnderscores = { - ...mockSettings, - field_schema: { - ...mockSettings.field_schema, - properties: { - ...mockSettings.field_schema.properties, - max_budget_per_user: { - type: "number", - description: "Max budget per user", - }, - }, - }, - values: { - ...mockSettings.values, - max_budget_per_user: 500, - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Max Budget Per User")).toBeInTheDocument(); - }); - }); - - it("should display 'No schema information available' when schema is missing", async () => { - const settingsWithoutSchema = { - values: {}, - field_schema: null, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithoutSchema); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("No schema information available")).toBeInTheDocument(); - }); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled(); }); }); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index 33bfc783afd..a9c07cdbcf8 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -1,30 +1,96 @@ import React, { useState, useEffect } from "react"; -import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react"; -import { Typography, Spin, Switch, Select } from "antd"; -import { getDefaultTeamSettings, updateDefaultTeamSettings, modelAvailableCall } from "./networking"; +import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd"; +import { EditOutlined, SaveOutlined } from "@ant-design/icons"; +import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking"; import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; import NotificationsManager from "./molecules/notifications_manager"; import { ModelSelect } from "./ModelSelect/ModelSelect"; +const { Title, Text } = Typography; + interface TeamSSOSettingsProps { accessToken: string | null; userID: string; userRole: string; } -const TeamSSOSettings: React.FC = ({ accessToken, userID, userRole }) => { +const PERMISSION_OPTIONS = [ + "/key/generate", + "/key/update", + "/key/delete", + "/key/regenerate", + "/key/service-account/generate", + "/key/{key_id}/regenerate", + "/key/block", + "/key/unblock", + "/key/bulk_update", + "/key/{key_id}/reset_spend", +]; + +interface SettingRowProps { + label: string; + description: string; + isEditing: boolean; + viewContent: React.ReactNode; + editContent: React.ReactNode; +} + +const SettingRow: React.FC = ({ label, description, isEditing, viewContent, editContent }) => ( + + +
{label}
+
{description}
+ + +
{isEditing ? editContent : viewContent}
+ +
+); + +const NotSet = () => Not set; + +const renderTags = (values: string[], displayFn?: (v: string) => string) => { + if (!values || values.length === 0) return ; + return ( +
+ {values.map((v) => ( + + {displayFn ? displayFn(v) : v} + + ))} +
+ ); +}; + +interface SettingsValues { + max_budget: number | null; + budget_duration: string | null; + tpm_limit: number | null; + rpm_limit: number | null; + models: string[]; + team_member_permissions: string[]; +} + +const DEFAULT_VALUES: SettingsValues = { + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + models: [], + team_member_permissions: [], +}; + +const TeamSSOSettings: React.FC = ({ accessToken }) => { const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState(null); + const [values, setValues] = useState(DEFAULT_VALUES); const [isEditing, setIsEditing] = useState(false); - const [editedValues, setEditedValues] = useState({}); + const [editedValues, setEditedValues] = useState(DEFAULT_VALUES); const [saving, setSaving] = useState(false); - const [availableModels, setAvailableModels] = useState([]); - const { Paragraph } = Typography; - const { Option } = Select; + const [fetchError, setFetchError] = useState(false); useEffect(() => { - const fetchTeamSSOSettings = async () => { + const fetchSettings = async () => { if (!accessToken) { setLoading(false); return; @@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, try { const data = await getDefaultTeamSettings(accessToken); - setSettings(data); - setEditedValues(data.values || {}); - - // Fetch available models - if (accessToken) { - try { - const modelResponse = await modelAvailableCall(accessToken, userID, userRole); - if (modelResponse && modelResponse.data) { - const modelNames = modelResponse.data.map((model: { id: string }) => model.id); - setAvailableModels(modelNames); - } - } catch (error) { - console.error("Error fetching available models:", error); - } - } + const fetched = { ...DEFAULT_VALUES, ...(data.values || {}) }; + setValues(fetched); + setEditedValues(fetched); } catch (error) { console.error("Error fetching team SSO settings:", error); + setFetchError(true); NotificationsManager.fromBackend("Failed to fetch team settings"); } finally { setLoading(false); } }; - fetchTeamSSOSettings(); + fetchSettings(); }, [accessToken]); - const handleSaveSettings = async () => { + const handleSave = async () => { if (!accessToken) return; setSaving(true); try { const updatedSettings = await updateDefaultTeamSettings(accessToken, editedValues); - setSettings({ ...settings, values: updatedSettings.settings }); + const newValues = { ...DEFAULT_VALUES, ...(updatedSettings.settings || {}) }; + setValues(newValues); + setEditedValues(newValues); setIsEditing(false); NotificationsManager.success("Default team settings updated successfully"); } catch (error) { @@ -75,129 +132,13 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, } }; - const handleTextInputChange = (key: string, value: any) => { - setEditedValues((prev: Record) => ({ - ...prev, - [key]: value, - })); + const handleCancel = () => { + setIsEditing(false); + setEditedValues(values); }; - const renderEditableField = (key: string, property: any, value: any) => { - const type = property.type; - - if (key === "budget_duration") { - return ( - handleTextInputChange(key, value)} - className="mt-2" - /> - ); - } else if (type === "boolean") { - return ( -
- handleTextInputChange(key, checked)} /> -
- ); - } else if (type === "array" && property.items?.enum) { - return ( - - ); - } else if (key === "models") { - return ( - handleTextInputChange(key, value)} - context="global" - style={{ width: "100%" }} - options={{ - includeSpecialOptions: true, - }} - /> - ); - } else if (type === "string" && property.enum) { - return ( - - ); - } else { - return ( - handleTextInputChange(key, e.target.value)} - placeholder={property.description || ""} - className="mt-2" - /> - ); - } - }; - - const renderValue = (key: string, value: any): JSX.Element => { - if (value === null || value === undefined) return Not set; - - if (key === "budget_duration") { - return {getBudgetDurationLabel(value)}; - } - - if (typeof value === "boolean") { - return {value ? "Enabled" : "Disabled"}; - } - - if (key === "models" && Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((model, index) => ( - - {getModelDisplayName(model)} - - ))} -
- ); - } - - if (typeof value === "object") { - if (Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((item, index) => ( - - {typeof item === "object" ? JSON.stringify(item) : String(item)} - - ))} -
- ); - } - - return
{JSON.stringify(value, null, 2)}
; - } - - return {String(value)}; + const update = (key: K, value: SettingsValues[K]) => { + setEditedValues((prev) => ({ ...prev, [key]: value })); }; if (loading) { @@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - if (!settings) { + if (fetchError) { return ( No team settings available or you do not have permission to view them. @@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - // Dynamically render settings based on the schema - const renderSettings = () => { - const { values, field_schema } = settings; - - if (!field_schema || !field_schema.properties) { - return No schema information available; - } - - return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => { - const value = values[key]; - const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - - return ( -
- {displayName} - - {property.description || "No description available"} - - - {isEditing ? ( -
{renderEditableField(key, property, value)}
- ) : ( -
{renderValue(key, value)}
- )} -
- ); - }); - }; - return ( - -
- Default Team Settings - {!loading && - settings && - (isEditing ? ( -
- -
) : ( - - ))} + + )} +
- These settings will be applied by default when creating new teams. +
+ {/* Budget & Rate Limits */} +
+
Budget & Rate Limits
+
+ ${Number(values.max_budget).toLocaleString()} : + } + editContent={ + update("max_budget", v)} + placeholder="Not set" + prefix="$" + min={0} + /> + } + /> - {settings?.field_schema?.description && ( - {settings.field_schema.description} - )} - + {getBudgetDurationLabel(values.budget_duration)} : + } + editContent={ + update("budget_duration", v)} + style={{ maxWidth: 320 }} + /> + } + /> -
{renderSettings()}
+ {values.tpm_limit.toLocaleString()} : + } + editContent={ + update("tpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> + + {values.rpm_limit.toLocaleString()} : + } + editContent={ + update("rpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> +
+
+ + {/* Access & Permissions */} +
+
Access & Permissions
+
+ update("models", v)} + context="global" + style={{ width: "100%" }} + options={{ includeSpecialOptions: true }} + /> + } + /> + + update("team_member_permissions", v)} + placeholder="Select permissions" + tagRender={({ label, closable, onClose }) => ( + + {label} + + )} + > + {PERMISSION_OPTIONS.map((option) => ( + + {option} + + ))} + + } + /> +
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx new file mode 100644 index 00000000000..8b2b1d0e4b7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../tests/test-utils"; +import ToolPoliciesView from "./ToolPoliciesView"; + +vi.mock("@/components/ToolDetail", () => ({ + ToolDetail: ({ toolName, onBack }: { toolName: string; onBack: () => void }) => ( +
+ Detail: {toolName} + +
+ ), +})); + +vi.mock("@/components/ToolPolicies", () => ({ + ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => ( +
+ Tool Policies Overview + +
+ ), +})); + +describe("ToolPoliciesView", () => { + it("should render the overview by default", () => { + renderWithProviders(); + + expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); + }); + + it("should navigate to tool detail when a tool is selected", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /select tool/i })); + + expect(screen.getByText("Detail: my-tool")).toBeInTheDocument(); + expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument(); + }); + + it("should navigate back to overview when back is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /select tool/i })); + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); + expect(screen.queryByText("Detail: my-tool")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 92d9c25c6be..aaeb8ebb4be 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -22,7 +22,9 @@ import { Text, Title, } from "@tremor/react"; -import React, { useEffect, useState } from "react"; +import { ExportOutlined, LoadingOutlined } from "@ant-design/icons"; +import { Alert, Button } from "antd"; +import React, { useMemo, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; import { UsageExportHeader } from "../../../EntityUsageExport"; import type { EntityType } from "../../../EntityUsageExport/types"; @@ -35,6 +37,7 @@ import { userDailyActivityCall, } from "../../../networking"; import { getProviderLogoAndName } from "../../../provider_info_helpers"; +import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types"; import { valueFormatterSpend } from "../../utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -87,119 +90,64 @@ interface EntityUsageProps { dateValue: DateRangePickerValue; } +const ENTITY_FETCH_FNS: Record Promise> = { + tag: tagDailyActivityCall, + team: teamDailyActivityCall, + organization: organizationDailyActivityCall, + customer: customerDailyActivityCall, + agent: agentDailyActivityCall, + user: userDailyActivityCall, +}; + const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => { - const [spendData, setSpendData] = useState({ - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }); const { teams } = useTeams(); - - const [agentSpendData, setAgentSpendData] = useState({ - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }); - - const modelMetrics = processActivityData(spendData, "models", teams || []); - const keyMetrics = processActivityData(spendData, "api_keys", teams || []); - const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {}; const [selectedTags, setSelectedTags] = useState([]); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); const [topAgentsLimit, setTopAgentsLimit] = useState(5); - const fetchSpendData = async () => { - if (!accessToken || !dateValue.from || !dateValue.to) return; - // Create new Date objects to avoid mutating the original dates - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); - if (entityType === "tag") { - const data = await tagDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "team") { - const data = await teamDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "organization") { - const data = await organizationDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "customer") { - const data = await customerDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "agent") { - const data = await agentDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "user") { - const data = await userDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags[0] : null, - ); - setSpendData(data); - } else { - throw new Error("Invalid entity type"); - } - }; + const entityFilterArg = useMemo(() => { + if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null; + return selectedTags.length > 0 ? selectedTags : null; + }, [entityType, selectedTags]); - const fetchAgentSpendData = async () => { - if (!accessToken || !dateValue.from || !dateValue.to || entityType !== "team") return; - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); - try { - const data = await agentDailyActivityCall(accessToken, startTime, endTime, 1, null); - setAgentSpendData(data); - } catch (e) { - console.error("Failed to fetch agent activity data:", e); - } - }; + const fetchFn = ENTITY_FETCH_FNS[entityType]; + const enabled = !!accessToken && !!startTime && !!endTime; - useEffect(() => { - fetchSpendData(); - fetchAgentSpendData(); - }, [accessToken, dateValue, entityId, selectedTags]); + const { + data: spendDataRaw, + isFetchingMore, + progress, + cancelled, + cancel, + } = usePaginatedDailyActivity({ + fetchFn, + args: [accessToken, startTime, endTime, entityFilterArg], + enabled, + }); + + const spendData = spendDataRaw as unknown as EntitySpendData; + + const { + data: agentSpendDataRaw, + isFetchingMore: agentIsFetchingMore, + progress: agentProgress, + cancelled: agentCancelled, + cancel: agentCancel, + } = usePaginatedDailyActivity({ + fetchFn: agentDailyActivityCall, + args: [accessToken, startTime, endTime, null], + enabled: enabled && entityType === "team", + }); + + const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData; + + const modelMetrics = processActivityData(spendData, "models", teams || []); + const keyMetrics = processActivityData(spendData, "api_keys", teams || []); + const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {}; const getTopModels = () => { const modelSpend: { [key: string]: any } = {}; @@ -448,6 +396,78 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti return (
+ {isFetchingMore && ( + + + + Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will + update periodically as data loads. Moving off of this page will stop and reset this. To continue using + the UI in the meantime,{" "} + + open a new tab + + . + + +
+ } + /> + )} + {cancelled && ( + + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + + } + /> + )} + {agentIsFetchingMore && entityType === "team" && ( + + + + Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. + Charts will update periodically as data loads. Moving off of this page will stop and reset this. To + continue using the UI in the meantime,{" "} + + open a new tab + + . + + + + } + /> + )} + {agentCancelled && entityType === "team" && ( + + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + + } + /> + )} = ({ accessToken, entityType, enti - ) : <>} + ) : ( + <> + )} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index b9fe1687e6c..bbcddd572cd 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -251,6 +251,7 @@ vi.mock("@ant-design/icons", async () => { UserOutlined: Icon, DownOutlined: Icon, RightOutlined: Icon, + ExportOutlined: Icon, LoadingOutlined, }; }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index e81e4ceaa36..1495c7d3e5b 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -6,7 +6,8 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { DownOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { BarChart, Card, @@ -19,11 +20,10 @@ import { TabPanel, TabPanels, Text, - Title + Title, } from "@tremor/react"; -import { Alert, Segmented, Select, Tooltip, Typography } from "antd"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; -import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react"; +import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd"; +import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; @@ -31,7 +31,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; import { ActivityMetrics, processActivityData } from "../../activity_metrics"; import CloudZeroExportModal from "../../cloudzero_export_modal"; @@ -43,14 +42,15 @@ import { ChartLoader } from "../../shared/chart_loader"; import { Tag } from "../../tag_management/types"; import UserAgentActivity from "../../user_agent_activity"; import ViewUserSpend from "../../view_user_spend"; +import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity"; import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types"; import { valueFormatterSpend } from "../utils/value_formatters"; import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import TopKeyView from "./EntityUsage/TopKeyView"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; import UsageAIChatPanel from "./UsageAIChatPanel"; +import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; interface UsagePageProps { teams: Team[]; @@ -59,13 +59,12 @@ interface UsagePageProps { const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - const [userSpendData, setUserSpendData] = useState<{ - results: DailyData[]; - metadata: any; - }>({ results: [], metadata: {} }); + // Aggregated endpoint: try first, fall back to paginated if unavailable + const [aggregatedData, setAggregatedData] = useState<{ results: DailyData[]; metadata: any } | null>(null); + const [aggregatedFailed, setAggregatedFailed] = useState(false); + const [aggregatedLoading, setAggregatedLoading] = useState(false); // Separate loading states for better UX - const [loading, setLoading] = useState(false); const [isDateChanging, setIsDateChanging] = useState(false); // Create initial dates outside of state to prevent recreation @@ -128,8 +127,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const handleUserPopupScroll = (e: UIEvent) => { const target = e.currentTarget; - const scrollRatio = - (target.scrollTop + target.clientHeight) / target.scrollHeight; + const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) { fetchNextUsersPage(); } @@ -137,9 +135,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID - const [selectedUserId, setSelectedUserId] = useState( - isAdmin ? null : (userID || null) - ); + const [selectedUserId, setSelectedUserId] = useState(isAdmin ? null : userID || null); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); @@ -173,6 +169,67 @@ const UsagePage: React.FC = ({ teams, organizations }) => { } }, [isAdmin, userID]); + // For non-admins, always pass their own user_id + const effectiveUserId = isAdmin ? selectedUserId : userID || null; + + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); + + // Try aggregated endpoint first, fall back to paginated on failure + const aggregatedFetchIdRef = useRef(0); + useEffect(() => { + if (!accessToken || !startTime || !endTime) return; + const fetchId = ++aggregatedFetchIdRef.current; + setAggregatedLoading(true); + setAggregatedFailed(false); + setAggregatedData(null); + + userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId) + .then((data) => { + if (aggregatedFetchIdRef.current !== fetchId) return; + setAggregatedData(data); + setAggregatedLoading(false); + setIsDateChanging(false); + }) + .catch(() => { + if (aggregatedFetchIdRef.current !== fetchId) return; + setAggregatedFailed(true); + setAggregatedLoading(false); + }); + }, [accessToken, startTime, endTime, effectiveUserId]); + + // Paginated fallback — only enabled when aggregated endpoint fails + const paginatedResult = usePaginatedDailyActivity({ + fetchFn: userDailyActivityCall, + args: [accessToken, startTime, endTime, effectiveUserId], + enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, + }); + + // Derive userSpendData from whichever source is active + const userSpendData = useMemo(() => { + if (aggregatedData) return aggregatedData; + if (aggregatedFailed) return paginatedResult.data; + return { results: [] as DailyData[], metadata: {} as any }; + }, [aggregatedData, aggregatedFailed, paginatedResult.data]); + + const loading = aggregatedLoading || paginatedResult.loading; + + // Clear isDateChanging when paginated data starts arriving + useEffect(() => { + if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) { + setIsDateChanging(false); + } + }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); + + // Super responsive date change handler + const handleDateChange = useCallback((newValue: DateRangePickerValue) => { + // Instant visual feedback + setIsDateChanging(true); + + // Update date immediately for UI responsiveness + setDateValue(newValue); + }, []); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -300,7 +357,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { providerSpendMap[provider].metrics.successful_requests += metrics.metrics.successful_requests || 0; providerSpendMap[provider].metrics.failed_requests += metrics.metrics.failed_requests || 0; providerSpendMap[provider].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0; - providerSpendMap[provider].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0; + providerSpendMap[provider].metrics.cache_creation_input_tokens += + metrics.metrics.cache_creation_input_tokens || 0; }); }); @@ -362,494 +420,415 @@ const UsagePage: React.FC = ({ teams, organizations }) => { .slice(0, topKeysLimit); }, [userSpendData.results, topKeysLimit]); - const fetchUserSpendData = useCallback(async () => { - if (!accessToken || !dateValue.from || !dateValue.to) return; - - // For non-admins, always pass their own user_id - const effectiveUserId = isAdmin ? selectedUserId : (userID || null); - - setLoading(true); - - // Create new Date objects to avoid mutating the original dates - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); - - try { - // Prefer aggregated endpoint to avoid many page requests - try { - const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId); - setUserSpendData(aggregated); - return; - } catch (e) { - // Fallback to paginated calls if aggregated endpoint is unavailable - } - - const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId); - - if (firstPageData.metadata.total_pages <= 1) { - setUserSpendData(firstPageData); - return; - } - - const allResults = [...firstPageData.results]; - const aggregatedMetadata = { ...firstPageData.metadata }; - - for (let page = 2; page <= firstPageData.metadata.total_pages; page++) { - const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId); - allResults.push(...pageData.results); - if (pageData.metadata) { - aggregatedMetadata.total_spend = (aggregatedMetadata.total_spend || 0) + (pageData.metadata.total_spend || 0); - aggregatedMetadata.total_api_requests = (aggregatedMetadata.total_api_requests || 0) + (pageData.metadata.total_api_requests || 0); - aggregatedMetadata.total_successful_requests = (aggregatedMetadata.total_successful_requests || 0) + (pageData.metadata.total_successful_requests || 0); - aggregatedMetadata.total_failed_requests = (aggregatedMetadata.total_failed_requests || 0) + (pageData.metadata.total_failed_requests || 0); - aggregatedMetadata.total_tokens = (aggregatedMetadata.total_tokens || 0) + (pageData.metadata.total_tokens || 0); - aggregatedMetadata.total_prompt_tokens = (aggregatedMetadata.total_prompt_tokens || 0) + (pageData.metadata.total_prompt_tokens || 0); - aggregatedMetadata.total_completion_tokens = (aggregatedMetadata.total_completion_tokens || 0) + (pageData.metadata.total_completion_tokens || 0); - aggregatedMetadata.total_cache_read_input_tokens = (aggregatedMetadata.total_cache_read_input_tokens || 0) + (pageData.metadata.total_cache_read_input_tokens || 0); - aggregatedMetadata.total_cache_creation_input_tokens = (aggregatedMetadata.total_cache_creation_input_tokens || 0) + (pageData.metadata.total_cache_creation_input_tokens || 0); - } - } - - setUserSpendData({ - results: allResults, - metadata: aggregatedMetadata, - }); - } catch (error) { - console.error("Error fetching user spend data:", error); - } finally { - setLoading(false); - setIsDateChanging(false); - } - }, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]); - - // Super responsive date change handler - const handleDateChange = useCallback((newValue: DateRangePickerValue) => { - // Instant visual feedback - setIsDateChanging(true); - setLoading(true); - - // Update date immediately for UI responsiveness - setDateValue(newValue); - }, []); - - // Debounced effect for data fetching with shorter delay - useEffect(() => { - if (!dateValue.from || !dateValue.to) return; - - const timeoutId = setTimeout(() => { - fetchUserSpendData(); - }, 50); // Very short debounce - - return () => clearTimeout(timeoutId); - }, [fetchUserSpendData]); - const sortedDailyResults = useMemo( () => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), [userSpendData.results], ); const modelMetrics = useMemo(() => processActivityData(userSpendData, "models", teams), [userSpendData, teams]); const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]); - const mcpServerMetrics = useMemo(() => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams]); + const mcpServerMetrics = useMemo( + () => processActivityData(userSpendData, "mcp_servers", teams), + [userSpendData, teams], + ); return (
- {/* Export Data Button - Positioned in top right corner */} - {/* {all_admin_roles.includes(userRole || "") && ( -
- -
- )} */} - {/* Global Date Picker and Tabs - Single Row */}
- setUsageView(value)} - isAdmin={isAdmin} - /> + setUsageView(value)} isAdmin={isAdmin} />
+ {paginatedResult.isFetchingMore && ( + + + + Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "} + {paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving + off of this page will stop and reset this. To continue using the UI in the meantime,{" "} + + open a new tab + + . + + +
+ } + /> + )} + {paginatedResult.cancelled && ( + + Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}{" "} + pages loaded) + + } + /> + )} {/* Your Usage Panel */} {usageView === "global" && ( <> - {isAdmin && ( -
- Filter by user - setSelectedUserId(value ?? null)} + filterOption={false} + onSearch={handleUserSearchChange} + searchValue={userSearchInput} + onPopupScroll={handleUserPopupScroll} + loading={isLoadingUsers} + notFoundContent={isLoadingUsers ? : "No users found"} + options={userOptions} + popupRender={(menu) => ( + <> + {menu} + {isFetchingNextUsersPage && ( +
+ +
+ )} + )} - > - Ask AI - - + />
-
- - {/* Cost Panel */} - - - {/* Total Spend Card */} - -
- - Project Spend{" "} - {dateValue.from && dateValue.to && ( - <> - {dateValue.from.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, - })} - {" - "} - {dateValue.to.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - )} - -
+ )} + +
+ + Cost + Model Activity + Key Activity + MCP Server Activity + Endpoint Activity + +
+ + +
+
+ + {/* Cost Panel */} + + + {/* Total Spend Card */} + +
+ + Project Spend{" "} + {dateValue.from && dateValue.to && ( + <> + {dateValue.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: + dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, + })} + {" - "} + {dateValue.to.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + )} + +
- - + + - - - Usage Metrics - - - Total Requests - - {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} - - - - Successful Requests - - {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} - - - -
- Failed Requests - - - -
- - {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} - -
- - Average Cost per Request - - $ - {formatNumberWithCommas( - (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), - 4, - )} - - - setShowTokenBreakdown(!showTokenBreakdown)} - > -
- Total Tokens - {showTokenBreakdown ? ( - - ) : ( - - )} -
- - {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} - -
-
- {showTokenBreakdown && ( - + + + Usage Metrics + - Input Tokens - - {userSpendData.metadata?.total_prompt_tokens?.toLocaleString() || 0} + Total Requests + + {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} - Output Tokens - - {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} - - - - Cache Read Tokens + Successful Requests - {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} + {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} - Cache Write Tokens - - {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} +
+ Failed Requests + + + +
+ + {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} + +
+ + Average Cost per Request + + $ + {formatNumberWithCommas( + (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), + 4, + )} + + + setShowTokenBreakdown(!showTokenBreakdown)} + > +
+ Total Tokens + {showTokenBreakdown ? ( + + ) : ( + + )} +
+ + {userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
- )} -
- + {showTokenBreakdown && ( + + + Input Tokens + + {userSpendData.metadata?.total_prompt_tokens?.toLocaleString() || 0} + + + + Output Tokens + + {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} + + + + Cache Read Tokens + + {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} + + + + Cache Write Tokens + + {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} + + + + )} +
+ - {/* Daily Spend Chart */} - - - Daily Spend - {loading ? ( - - ) : ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.date}

-

- Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} -

-

Requests: {data.metrics.api_requests}

-

Successful: {data.metrics.successful_requests}

-

Failed: {data.metrics.failed_requests}

-

Tokens: {data.metrics.total_tokens}

-
- ); - }} + {/* Daily Spend Chart */} + + + Daily Spend + {loading ? ( + + ) : ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.date}

+

+ Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} +

+

Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Tokens: {data.metrics.total_tokens}

+
+ ); + }} + /> + )} +
+ + {/* Top API Keys */} + + + Top Virtual Keys + - )} - - - {/* Top API Keys */} - - - Top Virtual Keys - + + + {/* Top Models */} + + + {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} +
+ setTopModelsLimit(value as number)} + /> +
+ + +
+
+ {loading ? ( + + ) : ( +
+ {(() => { + const modelData = modelViewType === "groups" ? topModelGroups : topModels; + return ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.key}

+

+ Spend: ${formatNumberWithCommas(data.spend, 2)} +

+

+ Total Requests: {data.requests.toLocaleString()} +

+

+ Successful: {data.successful_requests.toLocaleString()} +

+

+ Failed: {data.failed_requests.toLocaleString()} +

+

Tokens: {data.tokens.toLocaleString()}

+
+ ); + }} + /> + ); + })()} +
+ )} +
+ + + {/* Spend by Provider */} + + -
- + - {/* Top Models */} - - - {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} -
- setTopModelsLimit(value as number)} - /> -
- - -
-
- {loading ? ( - - ) : ( -
- {(() => { - const modelData = - modelViewType === "groups" - ? topModelGroups - : topModels; - return ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.key}

-

Spend: ${formatNumberWithCommas(data.spend, 2)}

-

- Total Requests: {data.requests.toLocaleString()} -

-

- Successful: {data.successful_requests.toLocaleString()} -

-

Failed: {data.failed_requests.toLocaleString()}

-

Tokens: {data.tokens.toLocaleString()}

-
- ); - }} - /> - ); - })()} -
- )} -
- + {/* Usage Metrics */} +
+
- {/* Spend by Provider */} - - - - - {/* Usage Metrics */} -
-
- - {/* Activity Panel */} - - - - - - - - - - - - -
- + {/* Activity Panel */} + + + + + + + + + + + + + + )} {/* Organization Usage Panel */} @@ -991,11 +970,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> {/* AI Chat Panel */} - setIsAiChatOpen(false)} - accessToken={accessToken} - /> + setIsAiChatOpen(false)} accessToken={accessToken} />
); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts new file mode 100644 index 00000000000..9a7ab22c9af --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { DailyData } from "../types"; + +export interface PaginationProgress { + currentPage: number; + totalPages: number; +} + +/** Delay between sequential page fetches (ms) to avoid overloading the backend. */ +const PAGE_FETCH_DELAY_MS = 300; + +/** Number of pages to accumulate before flushing to React state (reduces re-renders). */ +const RENDER_BATCH_SIZE = 3; + +/** The metadata fields returned by the daily activity API that should be summed across pages. */ +const SUMMABLE_METADATA_KEYS = [ + "total_spend", + "total_prompt_tokens", + "total_completion_tokens", + "total_tokens", + "total_api_requests", + "total_successful_requests", + "total_failed_requests", + "total_cache_read_input_tokens", + "total_cache_creation_input_tokens", +] as const; + +interface DailyActivityResponse { + results: DailyData[]; + metadata: Record; +} + +type FetchPageFn = (...args: any[]) => Promise; + +interface UsePaginatedDailyActivityParams { + /** The API call function (e.g., userDailyActivityCall). */ + fetchFn: FetchPageFn; + /** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */ + args: any[]; + /** Whether the hook should fetch. Set to false to disable. */ + enabled: boolean; +} + +interface UsePaginatedDailyActivityReturn { + data: DailyActivityResponse; + loading: boolean; + isFetchingMore: boolean; + progress: PaginationProgress; + cancelled: boolean; + cancel: () => void; +} + +const EMPTY_DATA: DailyActivityResponse = { + results: [], + metadata: { + total_spend: 0, + total_prompt_tokens: 0, + total_completion_tokens: 0, + total_tokens: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_pages: 1, + has_more: false, + page: 1, + }, +}; + +function sumMetadata( + a: Record, + b: Record, +): Record { + const result = { ...a }; + for (const key of SUMMABLE_METADATA_KEYS) { + result[key] = (a[key] || 0) + (b[key] || 0); + } + return result; +} + +/** + * Hook that auto-paginates daily activity endpoints, updating state in batches + * so charts render progressively. Cancels on unmount, param changes, or + * manual cancel(). + * + * The `args` array should contain every argument the fetchFn expects EXCEPT + * the `page` parameter. The hook injects `page` as the 4th argument (index 3), + * matching the signature of all daily activity calls: + * (accessToken, startTime, endTime, page, ...rest) + */ +export function usePaginatedDailyActivity({ + fetchFn, + args, + enabled, +}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn { + const [data, setData] = useState(EMPTY_DATA); + const [loading, setLoading] = useState(false); + const [isFetchingMore, setIsFetchingMore] = useState(false); + const [progress, setProgress] = useState({ + currentPage: 0, + totalPages: 0, + }); + const [cancelled, setCancelled] = useState(false); + + const fetchIdRef = useRef(0); + const cancelledRef = useRef(false); + const delayTimerRef = useRef | null>(null); + + // Keep args in a ref so the effect can always read the latest values + // without needing them in the dependency array. + const argsRef = useRef(args); + argsRef.current = args; + + // Stable serialised key so the effect only re-runs when the arg *values* change. + const argsKey = JSON.stringify(args); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setCancelled(true); + setIsFetchingMore(false); + if (delayTimerRef.current !== null) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + }, []); + + useEffect(() => { + if (!enabled) { + setData(EMPTY_DATA); + setLoading(false); + setIsFetchingMore(false); + setProgress({ currentPage: 0, totalPages: 0 }); + setCancelled(false); + return; + } + + const currentFetchId = ++fetchIdRef.current; + cancelledRef.current = false; + setCancelled(false); + + const isStale = () => + fetchIdRef.current !== currentFetchId || cancelledRef.current; + + /** Cancellable delay that clears itself on cleanup. */ + const delay = (ms: number) => + new Promise((resolve) => { + delayTimerRef.current = setTimeout(() => { + delayTimerRef.current = null; + resolve(); + }, ms); + }); + + const run = async () => { + const currentArgs = argsRef.current; + setLoading(true); + setIsFetchingMore(false); + setProgress({ currentPage: 1, totalPages: 1 }); + + try { + // Inject page=1 as the 4th argument. + const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)]; + const firstPage = await fetchFn(...argsWithPage); + + if (isStale()) return; + + setData(firstPage); + + const totalPages = firstPage.metadata?.total_pages || 1; + + setProgress({ currentPage: 1, totalPages }); + + if (totalPages <= 1) { + setLoading(false); + return; + } + + // More pages — start fetching sequentially. + setLoading(false); + setIsFetchingMore(true); + + let accumulatedResults = [...firstPage.results]; + let accumulatedMetadata = { ...firstPage.metadata }; + + for (let page = 2; page <= totalPages; page++) { + if (isStale()) return; + + // Small delay to avoid overwhelming the backend. + await delay(PAGE_FETCH_DELAY_MS); + + if (isStale()) return; + + const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)]; + const pageData = await fetchFn(...argsForPage); + + if (isStale()) return; + + accumulatedResults = [...accumulatedResults, ...pageData.results]; + accumulatedMetadata = sumMetadata( + accumulatedMetadata, + pageData.metadata, + ); + accumulatedMetadata.total_pages = totalPages; + accumulatedMetadata.has_more = page < totalPages; + accumulatedMetadata.page = page; + + // Flush accumulated data and progress to React state every + // RENDER_BATCH_SIZE pages (or on the final page) to avoid + // expensive per-page re-renders. Progress and data are updated + // together so the counter never appears to decrement. + const isLastPage = page === totalPages; + const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0; + if (isLastPage || isBatchBoundary) { + setData({ + results: accumulatedResults, + metadata: accumulatedMetadata, + }); + setProgress({ currentPage: page, totalPages }); + } + } + + setIsFetchingMore(false); + } catch (error) { + if (!isStale()) { + console.error("Error fetching daily activity:", error); + setLoading(false); + setIsFetchingMore(false); + } + } + }; + + run(); + + return () => { + fetchIdRef.current++; + if (delayTimerRef.current !== null) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + }; + // argsKey is a stable JSON string so the effect only re-fires when arg values change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, fetchFn, argsKey]); + + return { data, loading, isFetchingMore, progress, cancelled, cancel }; +} diff --git a/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx new file mode 100644 index 00000000000..0f928866a0d --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import AgentCard from "./agent_card"; +import type { Agent } from "./types"; + +const baseAgent: Agent = { + agent_id: "agent-123", + agent_name: "Test Agent", + litellm_params: { model: "gpt-4" }, + agent_card_params: { + description: "A test agent for unit testing", + url: "https://agent.example.com", + }, +}; + +const defaultProps = { + agent: baseAgent, + onAgentClick: vi.fn(), + accessToken: "token-123", + isAdmin: false, + onAgentUpdated: vi.fn(), +}; + +describe("AgentCard", () => { + it("should render the agent name and description", () => { + renderWithProviders(); + + expect(screen.getByText("Test Agent")).toBeInTheDocument(); + expect(screen.getByText("A test agent for unit testing")).toBeInTheDocument(); + }); + + it("should show 'No description' when agent has no description", () => { + const agent = { ...baseAgent, agent_card_params: {} }; + renderWithProviders(); + + expect(screen.getByText("No description")).toBeInTheDocument(); + }); + + it("should show the agent URL when provided", () => { + renderWithProviders(); + + expect(screen.getByText("https://agent.example.com")).toBeInTheDocument(); + }); + + it("should show 'Needs Setup' badge when agent has no key", () => { + renderWithProviders(); + + expect(screen.getByText("Needs Setup")).toBeInTheDocument(); + expect(screen.getByText("No key assigned")).toBeInTheDocument(); + }); + + it("should show 'Active' badge and key info when agent has a key", () => { + const keyInfo = { has_key: true, key_alias: "my-key" }; + renderWithProviders(); + + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("my-key")).toBeInTheDocument(); + }); + + it("should call onAgentClick when card is clicked", async () => { + const user = userEvent.setup(); + const onAgentClick = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByText("Test Agent")); + + expect(onAgentClick).toHaveBeenCalledWith("agent-123"); + }); + + it("should show delete button only for admins", () => { + const onDeleteClick = vi.fn(); + const { unmount } = renderWithProviders( + + ); + expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument(); + + unmount(); + + renderWithProviders( + + ); + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + }); + + it("should call onDeleteClick with agent id and name when delete is clicked", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: /delete/i })); + + expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx new file mode 100644 index 00000000000..1f6a61f39f8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import OrganizationDropdown from "./OrganizationDropdown"; + +const MOCK_ORGS = [ + { + organization_id: "org-1", + organization_alias: "Engineering", + budget_id: "", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + }, + { + organization_id: "org-2", + organization_alias: "Sales", + budget_id: "", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + }, +]; + +describe("OrganizationDropdown", () => { + it("should render", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should display organization options when opened", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Engineering")).toBeInTheDocument(); + expect(screen.getByText("Sales")).toBeInTheDocument(); + }); + + it("should call onChange with the org id when an organization is selected", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Engineering")); + + expect(onChange).toHaveBeenCalledWith("org-1", expect.anything()); + }); + + it("should add ant-select-disabled class when disabled prop is true", () => { + const { container } = render(); + expect(container.querySelector(".ant-select-disabled")).toBeTruthy(); + }); + + it("should render with empty organizations list", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx new file mode 100644 index 00000000000..ac93041f3d7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Select } from "antd"; +import { Organization } from "../networking"; + +interface OrganizationDropdownProps { + organizations?: Organization[] | null; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + loading?: boolean; +} + +const OrganizationDropdown: React.FC = ({ + organizations, + value, + onChange, + disabled, + loading, +}) => { + return ( + + ); +}; + +export default OrganizationDropdown; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 11a8a6f1fe3..bb9a0c1e015 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7206,7 +7206,6 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R const data = await response.json(); console.log("Updated default team settings:", data); - NotificationsManager.success("Default team settings updated successfully"); return data; } catch (error) { console.error("Failed to update default team settings:", error); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 3ed4c80aea5..eef7292dac1 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -213,7 +213,22 @@ vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () = vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null })); vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null })); vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null })); -vi.mock("../common_components/team_dropdown", () => ({ default: () => null })); +vi.mock("../common_components/team_dropdown", () => ({ + default: ({ teams, onChange, disabled }: { teams?: any[]; onChange?: (v: string) => void; disabled?: boolean }) => ( + + ), +})); vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null })); vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null })); vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null })); @@ -227,6 +242,31 @@ vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { organization_id: "org-1", organization_alias: "Engineering" }, + { organization_id: "org-2", organization_alias: "Sales" }, + ], + isLoading: false, + }), +})); + +vi.mock("../common_components/OrganizationDropdown", () => ({ + default: ({ value, onChange, disabled }: { value?: string; onChange?: (v: string) => void; disabled?: boolean }) => ( + + ), +})); + vi.mock("../common_components/ProjectDropdown", () => ({ default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => ( { expect(setFieldsValueMock).toHaveBeenCalledWith({ key_type: "management" }); }); }); + + describe("organization dropdown", () => { + it("should render the organization dropdown when modal is open", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + }); + + it("should disable the organization dropdown for non-admin users", async () => { + authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" }; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeDisabled(); + }); + }); + + it("should enable the organization dropdown for admin users", async () => { + authorizedState = { ...defaultAuthorizedState, userRole: "Admin" }; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).not.toBeDisabled(); + }); + }); + + it("should render team dropdown alongside organization dropdown", async () => { + const teamsWithOrg = [ + { team_id: "team-1", team_alias: "Team Alpha", organization_id: "org-1", models: [] }, + ]; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("team-dropdown")).toBeInTheDocument(); + }); + }); + + it("should set organization_id in form state when org is selected", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(screen.getByTestId("org-dropdown"), { target: { value: "org-1" } }); + }); + + expect(formStateRef.current["organization_id"]).toBe("org-1"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 0926b11fe09..71e882db3fb 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1,5 +1,6 @@ "use client"; import { keyKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -23,6 +24,7 @@ import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings" import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion"; import TeamDropdown from "../common_components/team_dropdown"; +import OrganizationDropdown from "../common_components/OrganizationDropdown"; import ProjectDropdown from "../common_components/ProjectDropdown"; import { CreateUserButton } from "../CreateUserButton"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; @@ -160,6 +162,7 @@ export const fetchUserModels = async ( const CreateKey: React.FC = ({ team, teams, data, addKey, autoOpenCreate, prefillData }) => { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); + const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects, isLoading: isProjectsLoading } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -179,6 +182,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [promptsList, setPromptsList] = useState([]); const [loggingSettings, setLoggingSettings] = useState([]); const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); @@ -207,6 +211,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedOrganizationId(null); setSelectedProjectId(null); }; @@ -224,6 +229,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedOrganizationId(null); setSelectedProjectId(null); }; @@ -752,6 +758,32 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp )} + + Organization{" "} + + + + + } + name="organization_id" + className="mt-4" + > + { + setSelectedOrganizationId(orgId || null); + // Clear team and project when org changes + setSelectedCreateKeyTeam(null); + setSelectedProjectId(null); + form.setFieldValue("team_id", undefined); + form.setFieldValue("project_id", undefined); + }} + /> + @@ -773,7 +805,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp help={keyOwner === "service_account" ? "required" : ""} > t.organization_id === selectedOrganizationId) : teams} disabled={selectedProjectId !== null} loading={!teams} onChange={(teamId) => { @@ -781,6 +813,14 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setSelectedCreateKeyTeam(selectedTeam); setSelectedProjectId(null); form.setFieldValue("project_id", undefined); + // Auto-populate org from team for non-admin users + if (selectedTeam?.organization_id) { + setSelectedOrganizationId(selectedTeam.organization_id); + form.setFieldValue("organization_id", selectedTeam.organization_id); + } else if (!teamId) { + setSelectedOrganizationId(null); + form.setFieldValue("organization_id", undefined); + } }} /> @@ -1531,6 +1571,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp excludedFields={[ "key_alias", "team_id", + "organization_id", "models", "duration", "metadata", diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index b00a8d1e3f8..2e4d0d97e4c 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -53,6 +53,16 @@ vi.mock("../organisms/create_key_button", () => ({ fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { organization_id: "org-1", organization_alias: "Engineering" }, + { organization_id: "org-2", organization_alias: "Sales" }, + ], + isLoading: false, + }), +})); + vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ useAccessGroups: vi.fn().mockReturnValue({ data: [ @@ -576,4 +586,91 @@ describe("KeyEditView", () => { resolveSubmit(); } }); + + describe("organization dropdown", () => { + it("should render the organization dropdown", async () => { + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + }); + + it("should disable the organization dropdown for non-admin users", async () => { + const { container } = renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Internal User" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); + const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); + expect(disabledSelect).toBeTruthy(); + }); + + it("should not disable the organization dropdown for admin users", async () => { + const { container } = renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); + const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); + expect(disabledSelect).toBeFalsy(); + }); + + it("should initialize organization from keyData", async () => { + const keyWithOrg = { + ...MOCK_KEY_DATA, + organization_id: "org-1", + }; + + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Engineering")).toBeInTheDocument(); + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index b6c00577c9b..cf431d10245 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -1,4 +1,5 @@ import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import PolicySelector from "@/components/policies/PolicySelector"; @@ -13,6 +14,7 @@ import { mapInternalToDisplayNames } from "../callback_info_helpers"; import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"; import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; +import OrganizationDropdown from "../common_components/OrganizationDropdown"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -96,10 +98,12 @@ export function KeyEditView({ ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) : [], ); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyData.organization_id || null); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); + const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -610,6 +614,28 @@ export function KeyEditView({ /> + + Organization{" "} + + + + + } + name="organization_id" + > + { + setSelectedOrganizationId(orgId || null); + form.setFieldValue("team_id", undefined); + }} + /> + + { + const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; + if (selectedTeam?.organization_id) { + setSelectedOrganizationId(selectedTeam.organization_id); + form.setFieldValue("organization_id", selectedTeam.organization_id); + } else if (!teamId) { + setSelectedOrganizationId(null); + form.setFieldValue("organization_id", undefined); + } + }} filterOption={(input, option) => { - const team = teams?.find((t) => t.team_id === option?.value); + const filteredTeams = selectedOrganizationId + ? teams?.filter((t) => t.organization_id === selectedOrganizationId) + : teams; + const team = filteredTeams?.find((t) => t.team_id === option?.value); if (!team) return false; return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false; }} > - {teams?.map((team) => ( + {(selectedOrganizationId + ? teams?.filter((t) => t.organization_id === selectedOrganizationId) + : teams + )?.map((team) => ( {`${team.team_alias} (${team.team_id})`} diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx new file mode 100644 index 00000000000..4a5cff3757a --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { CostBreakdownViewer, CostBreakdown } from "./CostBreakdownViewer"; + +describe("CostBreakdownViewer", () => { + it("should render nothing when there is no meaningful data", () => { + const { container } = renderWithProviders( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render nothing when costBreakdown is undefined", () => { + const { container } = renderWithProviders( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render the collapse header with heading and total", () => { + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + total_cost: 0.003, + }; + + renderWithProviders( + + ); + + expect(screen.getByRole("heading", { name: "Cost Breakdown" })).toBeInTheDocument(); + }); + + it("should show input and output costs when the panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText("Input Cost:")).toBeVisible(); + expect(screen.getByText("Output Cost:")).toBeVisible(); + expect(screen.getByText(/500 prompt tokens/)).toBeVisible(); + expect(screen.getByText(/200 completion tokens/)).toBeVisible(); + }); + + it("should show '(Cached)' in the header when cacheHit is true", () => { + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + total_cost: 0.003, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/\(Cached\)/)).toBeInTheDocument(); + }); + + it("should show discount label with percentage when panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.01, + output_cost: 0.02, + discount_percent: 0.1, + discount_amount: 0.003, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText(/Discount \(10\.00%\)/)).toBeVisible(); + }); + + it("should show margin label with percentage when panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.01, + output_cost: 0.02, + margin_percent: 0.15, + margin_total_amount: 0.005, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText(/Margin \(15\.00%\)/)).toBeVisible(); + expect(screen.getByText("Final Calculated Cost:")).toBeVisible(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx index 201c686de2c..481a2c6668d 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx @@ -1,32 +1,47 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import UserInfoView from "./user_info_view"; -vi.mock("../networking", () => { - const MOCK_USER_DATA = { - user_id: "user-123", - user_email: "test@example.com", - user_alias: "Test Alias", - user_role: "admin", - spend: 0, - max_budget: 100, - models: [], - budget_duration: "30d", - budget_reset_at: null, - metadata: {}, - created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-02T00:00:00.000Z", - sso_user_id: null, - teams: [], - }; +const mockTeamMemberAddCall = vi.fn(); +const mockTeamMemberDeleteCall = vi.fn(); +const mockTeamListCall = vi.fn(); +const mockUserGetInfoV2 = vi.fn(); +const mockTeamInfoCall = vi.fn(); +const MOCK_USER_DATA = { + user_id: "user-123", + user_email: "test@example.com", + user_alias: "Test Alias", + user_role: "admin", + spend: 0, + max_budget: 100, + models: [], + budget_duration: "30d", + budget_reset_at: null, + metadata: {}, + created_at: "2025-01-01T00:00:00.000Z", + updated_at: "2025-01-02T00:00:00.000Z", + sso_user_id: null, + teams: ["team-1", "team-2"], +}; + +const MOCK_USER_DATA_NO_TEAMS = { + ...MOCK_USER_DATA, + teams: [], +}; + +vi.mock("../networking", () => { return { - userGetInfoV2: vi.fn().mockResolvedValue(MOCK_USER_DATA), + userGetInfoV2: (...args: any[]) => mockUserGetInfoV2(...args), userDeleteCall: vi.fn(), userUpdateUserCall: vi.fn(), modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), invitationCreateCall: vi.fn(), - teamInfoCall: vi.fn().mockResolvedValue({ team_alias: "Test Team" }), + teamInfoCall: (...args: any[]) => mockTeamInfoCall(...args), + teamListCall: (...args: any[]) => mockTeamListCall(...args), + teamMemberAddCall: (...args: any[]) => mockTeamMemberAddCall(...args), + teamMemberDeleteCall: (...args: any[]) => mockTeamMemberDeleteCall(...args), getProxyBaseUrl: () => "https://litellm.test", }; }); @@ -36,10 +51,30 @@ describe("UserInfoView", () => { userId: "user-123", onClose: vi.fn(), accessToken: "test-token", - userRole: null, + userRole: null as string | null, possibleUIRoles: null, }; + beforeEach(() => { + vi.clearAllMocks(); + mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA); + mockTeamInfoCall.mockImplementation((_token: string, teamId: string) => { + const teamMap: Record = { + "team-1": { team_id: "team-1", team_info: { team_alias: "Alpha Team" } }, + "team-2": { team_id: "team-2", team_info: { team_alias: "Beta Team" } }, + "team-3": { team_id: "team-3", team_info: { team_alias: "Gamma Team" } }, + }; + return Promise.resolve(teamMap[teamId] || { team_id: teamId, team_info: { team_alias: null } }); + }); + mockTeamListCall.mockResolvedValue([ + { team_id: "team-1", team_alias: "Alpha Team" }, + { team_id: "team-2", team_alias: "Beta Team" }, + { team_id: "team-3", team_alias: "Gamma Team" }, + ]); + mockTeamMemberAddCall.mockResolvedValue({}); + mockTeamMemberDeleteCall.mockResolvedValue({}); + }); + it("should render the loading state", () => { render(); @@ -60,4 +95,125 @@ describe("UserInfoView", () => { const aliases = await screen.findAllByText("Test Alias"); expect(aliases.length).toBeGreaterThan(0); }); + + it("should render teams in a table with team names", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + }); + }); + + it("should show 'No teams' when user has no teams", async () => { + mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA_NO_TEAMS); + render(); + + await waitFor(() => { + expect(screen.getByText("No teams")).toBeInTheDocument(); + }); + }); + + it("should show Add Team button for proxy admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Add Team")).toBeInTheDocument(); + }); + }); + + it("should not show Add Team button for non-proxy-admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + expect(screen.queryByText("Add Team")).not.toBeInTheDocument(); + }); + + it("should show delete buttons for proxy admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + // Should have the Actions column header + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should not show delete buttons for non-proxy-admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + }); + + it("should open the add team modal when Add Team is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Add Team")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Add Team")); + + await waitFor(() => { + expect(screen.getByText("Add User to Team")).toBeInTheDocument(); + }); + expect(mockTeamListCall).toHaveBeenCalledWith("test-token", null); + }); + + it("should open remove confirmation modal when delete is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + + // Find the row with Alpha Team and click its delete button + const alphaRow = screen.getByText("Alpha Team").closest("tr")!; + const deleteButton = within(alphaRow).getByRole("button"); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText("Remove from Team")).toBeInTheDocument(); + expect(screen.getByText(/Removing this user from the team will also delete any keys/)).toBeInTheDocument(); + }); + }); + + it("should call teamMemberDeleteCall when remove is confirmed", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + + // Click delete on Alpha Team + const alphaRow = screen.getByText("Alpha Team").closest("tr")!; + const deleteButton = within(alphaRow).getByRole("button"); + await user.click(deleteButton); + + // Confirm deletion + await waitFor(() => { + expect(screen.getByText("Remove from Team")).toBeInTheDocument(); + }); + + // The DeleteResourceModal's OK button has text "Delete" - find it within the modal + const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement; + const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i }); + await user.click(deleteConfirmButton); + + await waitFor(() => { + expect(mockTeamMemberDeleteCall).toHaveBeenCalledWith( + "test-token", + "team-1", + { role: "user", user_id: "user-123" } + ); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx index d0684d81e5d..e5ec60642fc 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import { Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, Badge } from "@tremor/react"; -import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; +import { + Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, + Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell, +} from "@tremor/react"; +import { ArrowLeftIcon, TrashIcon, RefreshIcon, PlusIcon } from "@heroicons/react/outline"; import { userGetInfoV2, UserInfoV2Response, @@ -10,8 +13,12 @@ import { invitationCreateCall, getProxyBaseUrl, teamInfoCall, + teamListCall, + teamMemberAddCall, + teamMemberDeleteCall, + Member, } from "../networking"; -import { Button as AntdButton } from "antd"; +import { Button as AntdButton, Modal, Select as AntdSelect, Form, Tooltip } from "antd"; import { rolesWithWriteAccess } from "../../utils/roles"; import { UserEditView } from "../user_edit_view"; import OnboardingModal, { InvitationLink } from "../onboarding_link"; @@ -61,6 +68,15 @@ export default function UserInfoView({ const [activeTab, setActiveTab] = useState(initialTab); const [copiedStates, setCopiedStates] = useState>({}); const [isTeamsExpanded, setIsTeamsExpanded] = useState(false); + const [isAddTeamModalOpen, setIsAddTeamModalOpen] = useState(false); + const [isRemoveTeamModalOpen, setIsRemoveTeamModalOpen] = useState(false); + const [teamToRemove, setTeamToRemove] = useState(null); + const [isAddingTeam, setIsAddingTeam] = useState(false); + const [isRemovingTeam, setIsRemovingTeam] = useState(false); + const [allTeams, setAllTeams] = useState>([]); + const [selectedTeamId, setSelectedTeamId] = useState(""); + const [selectedRole, setSelectedRole] = useState("user"); + const [isLoadingTeams, setIsLoadingTeams] = useState(false); React.useEffect(() => { setBaseUrl(getProxyBaseUrl()); @@ -82,7 +98,7 @@ export default function UserInfoView({ const teamData = await teamInfoCall(accessToken, teamId); return { team_id: teamId, - team_alias: teamData?.team_alias || null, + team_alias: teamData?.team_info?.team_alias || null, }; } catch { return { team_id: teamId, team_alias: null }; @@ -111,6 +127,118 @@ export default function UserInfoView({ fetchData(); }, [accessToken, userId, userRole]); + const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; + + const fetchAllTeams = async () => { + if (!accessToken) return; + setIsLoadingTeams(true); + try { + const teams = await teamListCall(accessToken, null); + setAllTeams( + (teams || []).map((t: any) => ({ + team_id: t.team_id, + team_alias: t.team_alias || t.team_id, + })) + ); + } catch (error) { + console.error("Error fetching teams:", error); + } finally { + setIsLoadingTeams(false); + } + }; + + const handleOpenAddTeamModal = () => { + setSelectedTeamId(""); + setSelectedRole("user"); + setIsAddTeamModalOpen(true); + fetchAllTeams(); + }; + + const handleAddTeamSubmit = async () => { + if (!accessToken || !selectedTeamId) return; + setIsAddingTeam(true); + try { + const member: Member = { + role: selectedRole, + user_id: userId, + }; + await teamMemberAddCall(accessToken, selectedTeamId, member); + NotificationsManager.success("User added to team successfully"); + setIsAddTeamModalOpen(false); + // Re-fetch user data to refresh teams + const data = await userGetInfoV2(accessToken, userId); + setUserData(data); + if (data.teams && data.teams.length > 0) { + const teamPromises = data.teams.map(async (teamId: string) => { + try { + const teamData = await teamInfoCall(accessToken, teamId); + return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; + } catch { + return { team_id: teamId, team_alias: null }; + } + }); + setTeamDetails(await Promise.all(teamPromises)); + } else { + setTeamDetails([]); + } + } catch (error: any) { + console.error("Error adding user to team:", error); + NotificationsManager.fromBackend(error?.message || "Failed to add user to team"); + } finally { + setIsAddingTeam(false); + } + }; + + const handleOpenRemoveTeamModal = (team: TeamDisplayInfo) => { + setTeamToRemove(team); + setIsRemoveTeamModalOpen(true); + }; + + const handleRemoveTeamConfirm = async () => { + if (!accessToken || !teamToRemove) return; + setIsRemovingTeam(true); + try { + const member: Member = { + role: "user", + user_id: userId, + }; + await teamMemberDeleteCall(accessToken, teamToRemove.team_id, member); + NotificationsManager.success("User removed from team successfully"); + setIsRemoveTeamModalOpen(false); + setTeamToRemove(null); + // Re-fetch user data to refresh teams + const data = await userGetInfoV2(accessToken, userId); + setUserData(data); + if (data.teams && data.teams.length > 0) { + const teamPromises = data.teams.map(async (teamId: string) => { + try { + const teamData = await teamInfoCall(accessToken, teamId); + return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; + } catch { + return { team_id: teamId, team_alias: null }; + } + }); + setTeamDetails(await Promise.all(teamPromises)); + } else { + setTeamDetails([]); + } + } catch (error: any) { + console.error("Error removing user from team:", error); + NotificationsManager.fromBackend(error?.message || "Failed to remove user from team"); + } finally { + setIsRemovingTeam(false); + } + }; + + const handleRemoveTeamCancel = () => { + setIsRemoveTeamModalOpen(false); + setTeamToRemove(null); + }; + + const availableTeamsForAdd = allTeams.filter( + (t) => !teamDetails.some((td) => td.team_id === t.team_id) + ); + const handleResetPassword = async () => { if (!accessToken) { NotificationsManager.fromBackend("Access token not found"); @@ -312,37 +440,72 @@ export default function UserInfoView({
- Teams +
+ Teams + {isProxyAdmin && ( + + )} +
{teamDetails.length > 0 ? ( -
- {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => ( - - {team.team_alias || team.team_id} - - ))} - {!isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(true)} - > - +{teamDetails.length - 20} more - - )} - {isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(false)} - > - Show Less - - )} +
+ + + + Team Name + {isProxyAdmin && Actions} + + + + {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => ( + + {team.team_alias || team.team_id} + {isProxyAdmin && ( + +
) : ( No teams )} + {!isTeamsExpanded && teamDetails.length > 20 && ( + + )} + {isTeamsExpanded && teamDetails.length > 20 && ( + + )}
@@ -434,43 +597,6 @@ export default function UserInfoView({
-
- Teams -
- {teamDetails.length > 0 ? ( - <> - {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => ( - - {team.team_alias || team.team_id} - - ))} - {!isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(true)} - > - +{teamDetails.length - 20} more - - )} - {isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(false)} - > - Show Less - - )} - - ) : ( - No teams - )} -
-
-
Personal Models
@@ -519,6 +645,87 @@ export default function UserInfoView({ invitationLinkData={invitationLinkData} modalType="resetPassword" /> + + {/* Delete Team Member Modal */} + + + {/* Add to Team Modal */} + setIsAddTeamModalOpen(false)} + footer={null} + width={500} + maskClosable={!isAddingTeam} + > +
+ + { + const team = availableTeamsForAdd.find((t) => t.team_id === option?.value); + if (!team) return false; + return team.team_alias.toLowerCase().includes(input.toLowerCase()); + }} + loading={isLoadingTeams} + > + {availableTeamsForAdd.map((team) => ( + + {team.team_alias} + + ))} + + + + + + + + user + - Can view team info, but not manage it + + + + + admin + - Can create team keys, add members, and manage settings + + + + + +
+ + {isAddingTeam ? "Adding..." : "Add to Team"} + +
+
+
); } diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 8b05def9ba3..1d725572a33 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -77,6 +77,14 @@ vi.mock("@/components/networking", () => { // Called when decoding a valid token setGlobalLitellmHeaderName: vi.fn(), Organization: {}, + // Daily activity calls used by UsagePage components in the render tree + tagDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + teamDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + organizationDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + customerDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + agentDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + userDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + userDailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), }; }); diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index 5b0352feb98..d24bdd340f7 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ {