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:
yuneng-jiang 2026-03-14 14:22:27 -07:00 • committed by GitHub
commit a2708f332f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3482 additions and 1311 deletions

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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",
)

View file

@ -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.

View file

@ -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"}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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();
});
});

View 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");
});
});

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -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();
});
});

View file

@ -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>

View file

@ -251,6 +251,7 @@ vi.mock("@ant-design/icons", async () => {
UserOutlined: Icon,
DownOutlined: Icon,
RightOutlined: Icon,
ExportOutlined: Icon,
LoadingOutlined,
};
});

View file

@ -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 };
}

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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);

View file

@ -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");
});
});
});

View file

@ -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",

View file

@ -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();
});
});
});
});

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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" }
);
});
});
});

View file

@ -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>
);
}

View file

@ -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: {} }),
};
});

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{