mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge pull request #23658 from BerriAI/litellm_internal_dev_03_13_2026
[Infra] Merge internal dev 3/13 with main
This commit is contained in:
commit
a2708f332f
35 changed files with 3482 additions and 1311 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.<key> 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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@tremor/react")>();
|
||||
return {
|
||||
...actual,
|
||||
BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => (
|
||||
<div data-testid="bar-chart">
|
||||
{data.map((d, i) => (
|
||||
<span key={i}>
|
||||
{d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ScoreChart", () => {
|
||||
it("should render the title", () => {
|
||||
renderWithProviders(<ScoreChart />);
|
||||
|
||||
expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state when no data is provided", () => {
|
||||
renderWithProviders(<ScoreChart />);
|
||||
|
||||
expect(screen.getByText("No chart data for this period")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state when data is an empty array", () => {
|
||||
renderWithProviders(<ScoreChart data={[]} />);
|
||||
|
||||
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(<ScoreChart data={data} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
117
ui/litellm-dashboard/src/components/HelpLink.test.tsx
Normal file
117
ui/litellm-dashboard/src/components/HelpLink.test.tsx
Normal file
|
|
@ -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(<HelpLink href="https://docs.example.com" />);
|
||||
|
||||
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(
|
||||
<HelpLink href="https://docs.example.com">Custom docs link</HelpLink>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Custom docs link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should include a screen-reader-only label for accessibility", () => {
|
||||
renderWithProviders(<HelpLink href="https://docs.example.com" />);
|
||||
|
||||
expect(screen.getByText("(opens in a new tab)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HelpIcon", () => {
|
||||
it("should render a help button with accessible label", () => {
|
||||
renderWithProviders(<HelpIcon content="Some help text" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /help information/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show tooltip content on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<HelpIcon content="Tooltip help text" />);
|
||||
|
||||
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(
|
||||
<HelpIcon
|
||||
content="Help text"
|
||||
learnMoreHref="https://docs.example.com"
|
||||
learnMoreText="Read docs"
|
||||
/>
|
||||
);
|
||||
|
||||
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(<HelpIcon content="Help text" />);
|
||||
|
||||
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(<DocsMenu items={items} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show menu items when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DocsMenu items={items} />);
|
||||
|
||||
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(<DocsMenu items={items} />);
|
||||
|
||||
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(<DocsMenu items={items} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /docs/i });
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(button);
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
|
@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => {
|
|||
aria-label="Budget duration"
|
||||
>
|
||||
<option value="">Select duration</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="24h">Daily</option>
|
||||
<option value="7d">Weekly</option>
|
||||
<option value="30d">Monthly</option>
|
||||
</select>
|
||||
);
|
||||
BudgetDurationDropdown.displayName = "BudgetDurationDropdown";
|
||||
return {
|
||||
default: BudgetDurationDropdown,
|
||||
getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`),
|
||||
getBudgetDurationLabel: vi.fn((value: string) => {
|
||||
const map: Record<string, string> = { "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<typeof import("antd")>();
|
||||
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<HTMLInputElement>) => {
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
|
|
@ -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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display permissions as tags in view mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
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(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No schema information available")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TeamSSOSettingsProps> = ({ 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<SettingRowProps> = ({ label, description, isEditing, viewContent, editContent }) => (
|
||||
<Row className="py-5 border-b border-gray-100 last:border-0">
|
||||
<Col span={8} className="pr-6">
|
||||
<div className="text-sm font-semibold text-gray-900">{label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</div>
|
||||
</Col>
|
||||
<Col span={16} className="flex items-center">
|
||||
<div className="w-full">{isEditing ? editContent : viewContent}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
const NotSet = () => <Text className="text-gray-400 italic">Not set</Text>;
|
||||
|
||||
const renderTags = (values: string[], displayFn?: (v: string) => string) => {
|
||||
if (!values || values.length === 0) return <NotSet />;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.map((v) => (
|
||||
<Tag key={v} color="blue">
|
||||
{displayFn ? displayFn(v) : v}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<TeamSSOSettingsProps> = ({ accessToken }) => {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [editedValues, setEditedValues] = useState<any>({});
|
||||
const [editedValues, setEditedValues] = useState<SettingsValues>(DEFAULT_VALUES);
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const { Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
const [fetchError, setFetchError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeamSSOSettings = async () => {
|
||||
const fetchSettings = async () => {
|
||||
if (!accessToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ 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<TeamSSOSettingsProps> = ({ accessToken, userID,
|
|||
}
|
||||
};
|
||||
|
||||
const handleTextInputChange = (key: string, value: any) => {
|
||||
setEditedValues((prev: Record<string, any>) => ({
|
||||
...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 (
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues[key] || null}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
);
|
||||
} else if (type === "boolean") {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Switch checked={!!editedValues[key]} onChange={(checked) => handleTextInputChange(key, checked)} />
|
||||
</div>
|
||||
);
|
||||
} else if (type === "array" && property.items?.enum) {
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.items.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (key === "models") {
|
||||
return (
|
||||
<ModelSelect
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
context="global"
|
||||
style={{ width: "100%" }}
|
||||
options={{
|
||||
includeSpecialOptions: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (type === "string" && property.enum) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues[key] || ""}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextInput
|
||||
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
|
||||
onChange={(e) => 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 <span className="text-gray-400">Not set</span>;
|
||||
|
||||
if (key === "budget_duration") {
|
||||
return <span>{getBudgetDurationLabel(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return <span>{value ? "Enabled" : "Disabled"}</span>;
|
||||
}
|
||||
|
||||
if (key === "models" && Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((item, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{typeof item === "object" ? JSON.stringify(item) : String(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>;
|
||||
const update = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => {
|
||||
setEditedValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
|||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
if (fetchError) {
|
||||
return (
|
||||
<Card>
|
||||
<Text>No team settings available or you do not have permission to view them.</Text>
|
||||
|
|
@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
|||
);
|
||||
}
|
||||
|
||||
// Dynamically render settings based on the schema
|
||||
const renderSettings = () => {
|
||||
const { values, field_schema } = settings;
|
||||
|
||||
if (!field_schema || !field_schema.properties) {
|
||||
return <Text>No schema information available</Text>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
|
||||
<Text className="font-medium text-lg">{displayName}</Text>
|
||||
<Paragraph className="text-sm text-gray-500 mt-1">
|
||||
{property.description || "No description available"}
|
||||
</Paragraph>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-2">{renderEditableField(key, property, value)}</div>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded">{renderValue(key, value)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title className="text-xl">Default Team Settings</Title>
|
||||
{!loading &&
|
||||
settings &&
|
||||
(isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditedValues(settings.values || {});
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<Card styles={{ body: { padding: 32 } }}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<Title level={3} className="m-0 text-gray-900">
|
||||
Default Team Settings
|
||||
</Title>
|
||||
<Text className="text-gray-500 mt-1 block">
|
||||
These settings will be applied by default when creating new teams.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveSettings} loading={saving}>
|
||||
<Button type="primary" onClick={handleSave} loading={saving} icon={<SaveOutlined />}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
|
||||
))}
|
||||
<Button onClick={() => setIsEditing(true)} icon={<EditOutlined />}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text>These settings will be applied by default when creating new teams.</Text>
|
||||
<div className="mt-8">
|
||||
{/* Budget & Rate Limits */}
|
||||
<div className="mb-8">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Budget & Rate Limits</div>
|
||||
<div className="border-t border-gray-100">
|
||||
<SettingRow
|
||||
label="Max Budget"
|
||||
description="Maximum budget (in USD) for new automatically created teams."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.max_budget != null ? <Text>${Number(values.max_budget).toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.max_budget}
|
||||
onChange={(v) => update("max_budget", v)}
|
||||
placeholder="Not set"
|
||||
prefix="$"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{settings?.field_schema?.description && (
|
||||
<Paragraph className="mb-4 mt-2">{settings.field_schema.description}</Paragraph>
|
||||
)}
|
||||
<Divider />
|
||||
<SettingRow
|
||||
label="Budget Duration"
|
||||
description="How frequently the team's budget resets."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.budget_duration ? <Text>{getBudgetDurationLabel(values.budget_duration)}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues.budget_duration || null}
|
||||
onChange={(v) => update("budget_duration", v)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">{renderSettings()}</div>
|
||||
<SettingRow
|
||||
label="TPM Limit"
|
||||
description="Maximum tokens per minute allowed across all models."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.tpm_limit != null ? <Text>{values.tpm_limit.toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.tpm_limit}
|
||||
onChange={(v) => update("tpm_limit", v)}
|
||||
placeholder="Not set"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="RPM Limit"
|
||||
description="Maximum requests per minute allowed across all models."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.rpm_limit != null ? <Text>{values.rpm_limit.toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.rpm_limit}
|
||||
onChange={(v) => update("rpm_limit", v)}
|
||||
placeholder="Not set"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access & Permissions */}
|
||||
<div className="mb-8">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Access & Permissions</div>
|
||||
<div className="border-t border-gray-100">
|
||||
<SettingRow
|
||||
label="Models"
|
||||
description="Default list of models that new teams can access."
|
||||
isEditing={isEditing}
|
||||
viewContent={renderTags(values.models, getModelDisplayName)}
|
||||
editContent={
|
||||
<ModelSelect
|
||||
value={editedValues.models || []}
|
||||
onChange={(v) => update("models", v)}
|
||||
context="global"
|
||||
style={{ width: "100%" }}
|
||||
options={{ includeSpecialOptions: true }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="Team Member Permissions"
|
||||
description="Default permissions granted to members of newly created teams. /key/info and /key/health are always included."
|
||||
isEditing={isEditing}
|
||||
viewContent={renderTags(values.team_member_permissions)}
|
||||
editContent={
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues.team_member_permissions || []}
|
||||
onChange={(v) => update("team_member_permissions", v)}
|
||||
placeholder="Select permissions"
|
||||
tagRender={({ label, closable, onClose }) => (
|
||||
<Tag color="blue" closable={closable} onClose={onClose} className="mr-1 mt-1 mb-1">
|
||||
{label}
|
||||
</Tag>
|
||||
)}
|
||||
>
|
||||
{PERMISSION_OPTIONS.map((option) => (
|
||||
<Select.Option key={option} value={option}>
|
||||
{option}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div>
|
||||
<span>Detail: {toolName}</span>
|
||||
<button onClick={onBack}>Back</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ToolPolicies", () => ({
|
||||
ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => (
|
||||
<div>
|
||||
<span>Tool Policies Overview</span>
|
||||
<button onClick={() => onSelectTool("my-tool")}>Select Tool</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ToolPoliciesView", () => {
|
||||
it("should render the overview by default", () => {
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
|
||||
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to tool detail when a tool is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
|
||||
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(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
tag: tagDailyActivityCall,
|
||||
team: teamDailyActivityCall,
|
||||
organization: organizationDailyActivityCall,
|
||||
customer: customerDailyActivityCall,
|
||||
agent: agentDailyActivityCall,
|
||||
user: userDailyActivityCall,
|
||||
};
|
||||
|
||||
const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, entityId, entityList, dateValue }) => {
|
||||
const [spendData, setSpendData] = useState<EntitySpendData>({
|
||||
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<EntitySpendData>({
|
||||
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<string[]>([]);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
const [topAgentsLimit, setTopAgentsLimit] = useState<number>(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<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
{isFetchingMore && (
|
||||
<Alert
|
||||
banner
|
||||
type="warning"
|
||||
className="mb-2"
|
||||
message={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<LoadingOutlined spin className="mr-2" />
|
||||
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,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExportOutlined />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button type="primary" danger onClick={cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{cancelled && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
className="mb-2"
|
||||
message={
|
||||
<span>
|
||||
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{agentIsFetchingMore && entityType === "team" && (
|
||||
<Alert
|
||||
banner
|
||||
type="warning"
|
||||
className="mb-2"
|
||||
message={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<LoadingOutlined spin className="mr-2" />
|
||||
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,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExportOutlined />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button type="primary" danger onClick={agentCancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{agentCancelled && entityType === "team" && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
className="mb-2"
|
||||
message={
|
||||
<span>
|
||||
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<UsageExportHeader
|
||||
dateValue={dateValue}
|
||||
entityType={entityType}
|
||||
|
|
@ -772,7 +792,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={agentMetrics} />
|
||||
</TabPanel>
|
||||
) : <></>}
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />
|
||||
</TabPanel>
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ vi.mock("@ant-design/icons", async () => {
|
|||
UserOutlined: Icon,
|
||||
DownOutlined: Icon,
|
||||
RightOutlined: Icon,
|
||||
ExportOutlined: Icon,
|
||||
LoadingOutlined,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
|
||||
|
||||
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<string, any>,
|
||||
b: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
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<DailyActivityResponse>(EMPTY_DATA);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
||||
const [progress, setProgress] = useState<PaginationProgress>({
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
const fetchIdRef = useRef(0);
|
||||
const cancelledRef = useRef(false);
|
||||
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void>((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 };
|
||||
}
|
||||
|
|
@ -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(<AgentCard {...defaultProps} />);
|
||||
|
||||
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(<AgentCard {...defaultProps} agent={agent} />);
|
||||
|
||||
expect(screen.getByText("No description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the agent URL when provided", () => {
|
||||
renderWithProviders(<AgentCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("https://agent.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Needs Setup' badge when agent has no key", () => {
|
||||
renderWithProviders(<AgentCard {...defaultProps} />);
|
||||
|
||||
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(<AgentCard {...defaultProps} keyInfo={keyInfo} />);
|
||||
|
||||
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(<AgentCard {...defaultProps} onAgentClick={onAgentClick} />);
|
||||
|
||||
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(
|
||||
<AgentCard {...defaultProps} isAdmin={false} onDeleteClick={onDeleteClick} />
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
renderWithProviders(
|
||||
<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />
|
||||
);
|
||||
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(
|
||||
<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /delete/i }));
|
||||
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<OrganizationDropdown organizations={MOCK_ORGS} />);
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display organization options when opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OrganizationDropdown organizations={MOCK_ORGS} />);
|
||||
|
||||
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(<OrganizationDropdown organizations={MOCK_ORGS} onChange={onChange} />);
|
||||
|
||||
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(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
|
||||
expect(container.querySelector(".ant-select-disabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render with empty organizations list", () => {
|
||||
render(<OrganizationDropdown organizations={[]} />);
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<OrganizationDropdownProps> = ({
|
||||
organizations,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select an organization"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const org = organizations?.find((o) => o.organization_id === option.key);
|
||||
if (!org) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const orgAlias = (org.organization_alias || "").toLowerCase();
|
||||
const orgId = (org.organization_id || "").toLowerCase();
|
||||
|
||||
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<Select.Option key={org.organization_id} value={org.organization_id}>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<span className="text-gray-500">({org.organization_id})</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationDropdown;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<select
|
||||
data-testid="team-dropdown"
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
>
|
||||
<option value="">Select team</option>
|
||||
{teams?.map((t: any) => (
|
||||
<option key={t.team_id} value={t.team_id}>
|
||||
{t.team_alias}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
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 }) => (
|
||||
<select
|
||||
data-testid="org-dropdown"
|
||||
disabled={disabled}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
>
|
||||
<option value="">Select org</option>
|
||||
<option value="org-1">Engineering</option>
|
||||
<option value="org-2">Sales</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/ProjectDropdown", () => ({
|
||||
default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => (
|
||||
<input
|
||||
|
|
@ -408,4 +448,81 @@ describe("CreateKey", () => {
|
|||
expect(setFieldsValueMock).toHaveBeenCalledWith({ key_type: "management" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization dropdown", () => {
|
||||
it("should render the organization dropdown when modal is open", async () => {
|
||||
renderWithProviders(<CreateKey {...defaultProps} />);
|
||||
|
||||
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(<CreateKey {...defaultProps} />);
|
||||
|
||||
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(<CreateKey {...defaultProps} />);
|
||||
|
||||
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(<CreateKey {...defaultProps} teams={teamsWithOrg as any} />);
|
||||
|
||||
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(<CreateKey {...defaultProps} />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const [promptsList, setPromptsList] = useState<string[]>([]);
|
||||
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
|
||||
const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState<Team | null>(team);
|
||||
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string | null>(null);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false);
|
||||
const [newlyCreatedUserId, setNewlyCreatedUserId] = useState<string | null>(null);
|
||||
|
|
@ -207,6 +211,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Organization{" "}
|
||||
<Tooltip title="The organization this key belongs to. Selecting an organization filters the available teams.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="organization_id"
|
||||
className="mt-4"
|
||||
>
|
||||
<OrganizationDropdown
|
||||
organizations={organizations}
|
||||
loading={isOrganizationsLoading}
|
||||
disabled={userRole !== "Admin"}
|
||||
onChange={(orgId) => {
|
||||
setSelectedOrganizationId(orgId || null);
|
||||
// Clear team and project when org changes
|
||||
setSelectedCreateKeyTeam(null);
|
||||
setSelectedProjectId(null);
|
||||
form.setFieldValue("team_id", undefined);
|
||||
form.setFieldValue("project_id", undefined);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
|
@ -773,7 +805,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
help={keyOwner === "service_account" ? "required" : ""}
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
teams={selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams}
|
||||
disabled={selectedProjectId !== null}
|
||||
loading={!teams}
|
||||
onChange={(teamId) => {
|
||||
|
|
@ -781,6 +813,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
|
@ -1531,6 +1571,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
excludedFields={[
|
||||
"key_alias",
|
||||
"team_id",
|
||||
"organization_id",
|
||||
"models",
|
||||
"duration",
|
||||
"metadata",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<KeyEditView
|
||||
keyData={keyWithOrg}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
accessToken=""
|
||||
userID=""
|
||||
userRole="Admin"
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Engineering")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(keyData.organization_id || null);
|
||||
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
|
||||
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
|
||||
const [neverExpire, setNeverExpire] = useState<boolean>(!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({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Organization{" "}
|
||||
<Tooltip title="The organization this key belongs to. Selecting an organization filters the available teams.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="organization_id"
|
||||
>
|
||||
<OrganizationDropdown
|
||||
organizations={organizations}
|
||||
loading={isOrganizationsLoading}
|
||||
disabled={userRole !== "Admin"}
|
||||
onChange={(orgId) => {
|
||||
setSelectedOrganizationId(orgId || null);
|
||||
form.setFieldValue("team_id", undefined);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Team ID"
|
||||
name="team_id"
|
||||
|
|
@ -620,13 +646,29 @@ export function KeyEditView({
|
|||
showSearch
|
||||
disabled={enableProjectsUI && hasProject}
|
||||
style={{ width: "100%" }}
|
||||
onChange={(teamId) => {
|
||||
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) => (
|
||||
<Select.Option key={team.team_id} value={team.team_id}>
|
||||
{`${team.team_alias} (${team.team_id})`}
|
||||
</Select.Option>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<CostBreakdownViewer costBreakdown={null} totalSpend={0} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render nothing when costBreakdown is undefined", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CostBreakdownViewer costBreakdown={undefined} totalSpend={0} />
|
||||
);
|
||||
|
||||
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(
|
||||
<CostBreakdownViewer costBreakdown={breakdown} totalSpend={0.003} />
|
||||
);
|
||||
|
||||
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(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={breakdown}
|
||||
totalSpend={0.003}
|
||||
promptTokens={500}
|
||||
completionTokens={200}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={breakdown}
|
||||
totalSpend={0}
|
||||
cacheHit="true"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<CostBreakdownViewer costBreakdown={breakdown} totalSpend={0.027} />
|
||||
);
|
||||
|
||||
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(
|
||||
<CostBreakdownViewer costBreakdown={breakdown} totalSpend={0.035} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("heading", { name: "Cost Breakdown" }));
|
||||
|
||||
expect(screen.getByText(/Margin \(15\.00%\)/)).toBeVisible();
|
||||
expect(screen.getByText("Final Calculated Cost:")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, any> = {
|
||||
"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(<UserInfoView {...defaultProps} />);
|
||||
|
||||
|
|
@ -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(<UserInfoView {...defaultProps} />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No teams")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Add Team button for proxy admins", async () => {
|
||||
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Team")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show Add Team button for non-proxy-admins", async () => {
|
||||
render(<UserInfoView {...defaultProps} userRole="internal_user" />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} userRole="internal_user" />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
|
||||
|
||||
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(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
|
||||
|
||||
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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Record<string, boolean>>({});
|
||||
const [isTeamsExpanded, setIsTeamsExpanded] = useState(false);
|
||||
const [isAddTeamModalOpen, setIsAddTeamModalOpen] = useState(false);
|
||||
const [isRemoveTeamModalOpen, setIsRemoveTeamModalOpen] = useState(false);
|
||||
const [teamToRemove, setTeamToRemove] = useState<TeamDisplayInfo | null>(null);
|
||||
const [isAddingTeam, setIsAddingTeam] = useState(false);
|
||||
const [isRemovingTeam, setIsRemovingTeam] = useState(false);
|
||||
const [allTeams, setAllTeams] = useState<Array<{ team_id: string; team_alias: string }>>([]);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
|
||||
const [selectedRole, setSelectedRole] = useState<string>("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({
|
|||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<Text>Teams</Text>
|
||||
{isProxyAdmin && (
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleOpenAddTeamModal}
|
||||
>
|
||||
Add Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{teamDetails.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<Badge key={index} color="blue" title={team.team_alias || team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{teamDetails.length - 20} more
|
||||
</Badge>
|
||||
)}
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(false)}
|
||||
>
|
||||
Show Less
|
||||
</Badge>
|
||||
)}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Team Name</TableHeaderCell>
|
||||
{isProxyAdmin && <TableHeaderCell className="text-right">Actions</TableHeaderCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => (
|
||||
<TableRow key={team.team_id}>
|
||||
<TableCell>{team.team_alias || team.team_id}</TableCell>
|
||||
{isProxyAdmin && (
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => handleOpenRemoveTeamModal(team)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<Text>No teams</Text>
|
||||
)}
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{teamDetails.length - 20} more
|
||||
</Button>
|
||||
)}
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => setIsTeamsExpanded(false)}
|
||||
>
|
||||
Show Less
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -434,43 +597,6 @@ export default function UserInfoView({
|
|||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Teams</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{teamDetails.length > 0 ? (
|
||||
<>
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 rounded text-xs"
|
||||
title={team.team_alias || team.team_id}
|
||||
>
|
||||
{team.team_alias || team.team_id}
|
||||
</span>
|
||||
))}
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{teamDetails.length - 20} more
|
||||
</span>
|
||||
)}
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(false)}
|
||||
>
|
||||
Show Less
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text>No teams</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Personal Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
|
|
@ -519,6 +645,87 @@ export default function UserInfoView({
|
|||
invitationLinkData={invitationLinkData}
|
||||
modalType="resetPassword"
|
||||
/>
|
||||
|
||||
{/* Delete Team Member Modal */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isRemoveTeamModalOpen}
|
||||
title="Remove from Team"
|
||||
alertMessage="Removing this user from the team will also delete any keys the user created for this team."
|
||||
message="Are you sure you want to remove this user from the team? This action cannot be undone."
|
||||
resourceInformationTitle="Team Membership"
|
||||
resourceInformation={[
|
||||
{ label: "Team", value: teamToRemove?.team_alias || teamToRemove?.team_id },
|
||||
{ label: "User ID", value: userData?.user_id, code: true },
|
||||
{ label: "Email", value: userData?.user_email },
|
||||
]}
|
||||
onCancel={handleRemoveTeamCancel}
|
||||
onOk={handleRemoveTeamConfirm}
|
||||
confirmLoading={isRemovingTeam}
|
||||
/>
|
||||
|
||||
{/* Add to Team Modal */}
|
||||
<Modal
|
||||
title="Add User to Team"
|
||||
open={isAddTeamModalOpen}
|
||||
onCancel={() => setIsAddTeamModalOpen(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
maskClosable={!isAddingTeam}
|
||||
>
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleAddTeamSubmit}
|
||||
>
|
||||
<Form.Item label="Team" required>
|
||||
<AntdSelect
|
||||
showSearch
|
||||
value={selectedTeamId || undefined}
|
||||
onChange={setSelectedTeamId}
|
||||
placeholder="Select a team"
|
||||
filterOption={(input, option) => {
|
||||
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) => (
|
||||
<AntdSelect.Option key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias}
|
||||
</AntdSelect.Option>
|
||||
))}
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Member Role">
|
||||
<AntdSelect value={selectedRole} onChange={setSelectedRole}>
|
||||
<AntdSelect.Option value="user">
|
||||
<Tooltip title="Can view team info, but not manage it">
|
||||
<span className="font-medium">user</span>
|
||||
<span className="ml-2 text-gray-500 text-sm">- Can view team info, but not manage it</span>
|
||||
</Tooltip>
|
||||
</AntdSelect.Option>
|
||||
<AntdSelect.Option value="admin">
|
||||
<Tooltip title="Can create team keys, add members, and manage settings">
|
||||
<span className="font-medium">admin</span>
|
||||
<span className="ml-2 text-gray-500 text-sm">- Can create team keys, add members, and manage settings</span>
|
||||
</Tooltip>
|
||||
</AntdSelect.Option>
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
|
||||
<div className="text-right mt-4">
|
||||
<AntdButton
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isAddingTeam}
|
||||
disabled={!selectedTeamId}
|
||||
>
|
||||
{isAddingTeam ? "Adding..." : "Add to Team"}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue