mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Compare commits
67 commits
main
...
v1.82.6.de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cfc5b97de | ||
|
|
d8704a59db | ||
|
|
0ca2472d83 | ||
|
|
5da2f35fd3 | ||
|
|
b69fd49af6 | ||
|
|
efa5a3fc69 | ||
|
|
75bd742d18 | ||
|
|
9f7e65a92d | ||
|
|
33aa190b16 | ||
|
|
dae638b2f0 | ||
|
|
1a63a7bab8 | ||
|
|
5c953c6b61 | ||
|
|
8366d24e22 | ||
|
|
83eac06b32 | ||
|
|
456e326c95 | ||
|
|
a022956724 | ||
|
|
478ec3d392 | ||
|
|
baa1fa4151 | ||
|
|
79ca00bbec | ||
|
|
017d87b7ab | ||
|
|
535c368f3b | ||
|
|
e4611faf58 | ||
|
|
bb019c8920 | ||
|
|
bdab813f21 | ||
|
|
5151a835a3 | ||
|
|
f51078761a | ||
|
|
4dd2089eee | ||
|
|
c99257ddd4 | ||
|
|
9ff49bc75b | ||
|
|
cc17c370de | ||
|
|
c893324a5b | ||
|
|
334c455150 | ||
|
|
31bc13eb17 | ||
|
|
185db1941f | ||
|
|
9051bf1e63 | ||
|
|
2c4be6f5c7 | ||
|
|
9019207111 | ||
|
|
d1a0b94919 | ||
|
|
071e590f80 | ||
|
|
bca07dca61 | ||
|
|
5905100cdc | ||
|
|
290706a708 | ||
|
|
24be81094b | ||
|
|
42fe911e1c | ||
|
|
4f98b4ea3c | ||
|
|
18fae5377e | ||
|
|
db8050bb60 | ||
|
|
492c0cd3ba | ||
|
|
41d12ed106 | ||
|
|
80e55804af | ||
|
|
00f852cec4 | ||
|
|
8aa1ebfb07 | ||
|
|
b78e3bad85 | ||
|
|
7c9664147f | ||
|
|
5acb52b40a | ||
|
|
edd41cf88d | ||
|
|
d7890fb278 | ||
|
|
0ca13cc6a5 | ||
|
|
4f6b8a2a3e | ||
|
|
f80d7dc2f2 | ||
|
|
5d688c9f27 | ||
|
|
79aea5ddcf | ||
|
|
4b80094b90 | ||
|
|
049d951e71 | ||
|
|
fd975053ba | ||
|
|
c219302bfe | ||
|
|
a8be4d3d68 |
81 changed files with 4203 additions and 631 deletions
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.59-py3-none-any.whl
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.59-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.59.tar.gz
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.59.tar.gz
vendored
Normal file
Binary file not shown.
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "team_id" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY['*']::TEXT[];
|
||||
|
||||
|
|
@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
|
||||
agents String[] @default([])
|
||||
agent_access_groups String[] @default([])
|
||||
models String[] @default([])
|
||||
|
|
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
team_id String?
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.58"
|
||||
version = "0.4.59"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
authors = ["BerriAI"]
|
||||
readme = "README.md"
|
||||
|
|
@ -22,7 +22,7 @@ requires = ["poetry-core"]
|
|||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.58"
|
||||
version = "0.4.59"
|
||||
version_files = [
|
||||
"pyproject.toml:version",
|
||||
"../requirements.txt:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def _prepare_mcp_server_data(
|
|||
"""
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
# Convert model to dict
|
||||
# Convert model to dict, excluding fields that are not DB columns
|
||||
data_dict = data.model_dump(exclude_none=True)
|
||||
# Ensure alias is always present in the dict (even if None)
|
||||
if "alias" not in data_dict:
|
||||
|
|
|
|||
|
|
@ -669,6 +669,7 @@ class MCPServerManager:
|
|||
available_on_public_internet=bool(
|
||||
getattr(mcp_server, "available_on_public_internet", True)
|
||||
),
|
||||
team_id=getattr(mcp_server, "team_id", None),
|
||||
created_at=getattr(mcp_server, "created_at", None),
|
||||
updated_at=getattr(mcp_server, "updated_at", None),
|
||||
tool_name_to_display_name=_deserialize_json_dict(
|
||||
|
|
@ -2714,6 +2715,7 @@ class MCPServerManager:
|
|||
auth_type=server.auth_type,
|
||||
created_at=server.created_at,
|
||||
updated_at=server.updated_at,
|
||||
team_id=server.team_id,
|
||||
teams=[],
|
||||
mcp_access_groups=server.access_groups or [],
|
||||
allowed_tools=server.allowed_tools or [],
|
||||
|
|
@ -2805,6 +2807,7 @@ class MCPServerManager:
|
|||
auth_type=server.auth_type,
|
||||
created_at=server.created_at,
|
||||
updated_at=server.updated_at,
|
||||
team_id=server.team_id,
|
||||
teams=[],
|
||||
mcp_access_groups=server.access_groups or [],
|
||||
allowed_tools=server.allowed_tools or [],
|
||||
|
|
|
|||
|
|
@ -372,6 +372,10 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v1/search",
|
||||
"/search/{search_tool_name}",
|
||||
"/v1/search/{search_tool_name}",
|
||||
"/search_tools/list",
|
||||
"/search_tools/ui/available_providers",
|
||||
"/search/tools",
|
||||
"/v1/search/tools",
|
||||
# OCR
|
||||
"/ocr",
|
||||
"/v1/ocr",
|
||||
|
|
@ -655,6 +659,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/model/delete",
|
||||
"/user/daily/activity",
|
||||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
"/team/available_permissions", # read-only permission metadata; any authenticated user may read
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/model/{model_id}/update",
|
||||
"/prompt/list",
|
||||
|
|
@ -856,6 +861,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
|||
mcp_access_groups: Optional[List[str]] = None
|
||||
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None
|
||||
vector_stores: Optional[List[str]] = None
|
||||
search_tools: Optional[List[str]] = ["*"] # ["*"] = all access, [] = no access
|
||||
agents: Optional[List[str]] = None
|
||||
agent_access_groups: Optional[List[str]] = None
|
||||
models: Optional[List[str]] = None
|
||||
|
|
@ -1104,6 +1110,11 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
server_name: Optional[str] = None
|
||||
alias: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
team_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Team ID to scope this MCP server to. Required for non-proxy-admin users. "
|
||||
"When provided, the server is auto-assigned to the team's ObjectPermissionTable.",
|
||||
)
|
||||
transport: MCPTransportType = MCPTransport.sse
|
||||
auth_type: Optional[MCPAuthType] = None
|
||||
credentials: Optional[MCPCredentials] = None
|
||||
|
|
@ -1204,6 +1215,11 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
byok_description: List[str] = Field(default_factory=list)
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
team_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Team ID that owns this MCP server. Only proxy admins can change ownership. "
|
||||
"Set to null to make the server global.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -1239,6 +1255,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
|
|||
created_by: Optional[str] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
updated_by: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
|
||||
mcp_access_groups: List[str] = Field(default_factory=list)
|
||||
allowed_tools: List[str] = Field(default_factory=list)
|
||||
|
|
@ -1611,6 +1628,33 @@ class Member(MemberBase):
|
|||
] = Field(
|
||||
description="The role of the user within the team. 'admin' users can manage team settings and members, 'user' is a regular team member"
|
||||
)
|
||||
extra_permissions: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Granular permissions granted to this member (e.g. 'mcp:create', 'mcp:delete'). Used for per-member permission grants.",
|
||||
)
|
||||
|
||||
@field_validator("extra_permissions", mode="before")
|
||||
@classmethod
|
||||
def validate_permission_format(cls, v):
|
||||
"""Validate that all permission strings are known valid permissions."""
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("extra_permissions must be a list of strings")
|
||||
from litellm.proxy.auth.permissions import VALID_PERMISSIONS
|
||||
|
||||
for perm in v:
|
||||
if not isinstance(perm, str) or ":" not in perm:
|
||||
raise ValueError(
|
||||
f"Invalid permission format: '{perm}'. "
|
||||
"Must follow 'resource:action' format (e.g. 'mcp:create')."
|
||||
)
|
||||
if perm not in VALID_PERMISSIONS:
|
||||
raise ValueError(
|
||||
f"Unknown permission: '{perm}'. "
|
||||
f"Valid permissions: {sorted(VALID_PERMISSIONS)}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class OrgMember(MemberBase):
|
||||
|
|
@ -1844,6 +1888,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
|
|||
"""
|
||||
|
||||
vector_stores: Optional[List[str]] = []
|
||||
search_tools: Optional[List[str]] = ["*"] # ["*"] = all access, [] = no access
|
||||
agents: Optional[List[str]] = []
|
||||
agent_access_groups: Optional[List[str]] = []
|
||||
|
||||
|
|
@ -3530,6 +3575,21 @@ class ProxyErrorTypes(str, enum.Enum):
|
|||
Organization does not have access to the vector store
|
||||
"""
|
||||
|
||||
key_search_tool_access_denied = "key_search_tool_access_denied"
|
||||
"""
|
||||
Key does not have access to the search tool
|
||||
"""
|
||||
|
||||
team_search_tool_access_denied = "team_search_tool_access_denied"
|
||||
"""
|
||||
Team does not have access to the search tool
|
||||
"""
|
||||
|
||||
org_search_tool_access_denied = "org_search_tool_access_denied"
|
||||
"""
|
||||
Organization does not have access to the search tool
|
||||
"""
|
||||
|
||||
team_member_already_in_team = "team_member_already_in_team"
|
||||
"""
|
||||
Team member is already in team
|
||||
|
|
@ -3572,6 +3632,23 @@ class ProxyErrorTypes(str, enum.Enum):
|
|||
elif object_type == "org":
|
||||
return cls.org_vector_store_access_denied
|
||||
|
||||
@classmethod
|
||||
def get_search_tool_access_error_type_for_object(
|
||||
cls, object_type: Literal["key", "team", "org"]
|
||||
) -> "ProxyErrorTypes":
|
||||
"""
|
||||
Get the search tool access error type for object_type
|
||||
"""
|
||||
if object_type == "key":
|
||||
return cls.key_search_tool_access_denied
|
||||
elif object_type == "team":
|
||||
return cls.team_search_tool_access_denied
|
||||
elif object_type == "org":
|
||||
return cls.org_search_tool_access_denied
|
||||
raise ValueError(
|
||||
f"Unknown object_type '{object_type}' for search tool access error"
|
||||
)
|
||||
|
||||
|
||||
DB_CONNECTION_ERROR_TYPES = (
|
||||
httpx.ConnectError,
|
||||
|
|
@ -3729,6 +3806,10 @@ class TeamMemberDeleteRequest(MemberDeleteRequest):
|
|||
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
|
||||
max_budget_in_team: Optional[float] = None
|
||||
role: Optional[Literal["admin", "user"]] = None
|
||||
extra_permissions: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Granular permissions to grant to this team member (e.g. ['mcp:create', 'mcp:delete']). Replaces any existing extra_permissions.",
|
||||
)
|
||||
tpm_limit: Optional[int] = Field(
|
||||
default=None, description="Tokens per minute limit for this team member"
|
||||
)
|
||||
|
|
@ -3740,6 +3821,7 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
|
|||
class TeamMemberUpdateResponse(MemberUpdateResponse):
|
||||
team_id: str
|
||||
max_budget_in_team: Optional[float] = None
|
||||
extra_permissions: Optional[List[str]] = None
|
||||
tpm_limit: Optional[int] = None
|
||||
rpm_limit: Optional[int] = None
|
||||
|
||||
|
|
|
|||
|
|
@ -3574,3 +3574,177 @@ def _can_object_call_vector_stores(
|
|||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _can_object_call_search_tools(
|
||||
object_type: Literal["key", "team", "org"],
|
||||
search_tool_name: str,
|
||||
object_permissions: Optional[LiteLLM_ObjectPermissionTable],
|
||||
) -> bool:
|
||||
"""
|
||||
Raises ProxyException if the object (key, team, org) cannot access the specific search tool.
|
||||
|
||||
Key difference from vector stores: follows principle of least privilege.
|
||||
- object_permissions is None → allow (no permission record)
|
||||
- search_tools is None → allow (field not configured, no restriction)
|
||||
- search_tools == [] → DENY ALL (empty list = no access granted)
|
||||
- search_tool_name in search_tools → allow
|
||||
- search_tool_name not in search_tools → deny
|
||||
"""
|
||||
if object_permissions is None:
|
||||
return True
|
||||
|
||||
if object_permissions.search_tools is None:
|
||||
return True
|
||||
|
||||
# Wildcard "*" = all tools accessible (migration default)
|
||||
if "*" in object_permissions.search_tools:
|
||||
return True
|
||||
|
||||
# Empty list = no access (principle of least privilege)
|
||||
# Non-empty list = only listed tools are accessible
|
||||
if search_tool_name not in object_permissions.search_tools:
|
||||
raise ProxyException(
|
||||
message=f"User not allowed to access search tool '{search_tool_name}'. Allowed search tools: {object_permissions.search_tools}",
|
||||
type=ProxyErrorTypes.get_search_tool_access_error_type_for_object(
|
||||
object_type
|
||||
),
|
||||
param="search_tool",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def search_tool_access_check(
|
||||
search_tool_name: str,
|
||||
valid_token: Optional[UserAPIKeyAuth],
|
||||
):
|
||||
"""
|
||||
Checks if the key/team has access to a specific search tool.
|
||||
|
||||
Uses valid_token.object_permission_id (key level) and
|
||||
valid_token.team_object_permission_id (team level) to look up permissions
|
||||
via the cached get_object_permission helper.
|
||||
|
||||
Raises ProxyException if access is denied.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
verbose_proxy_logger.debug(
|
||||
"Prisma client not found, skipping search tool access check"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check key-level permissions
|
||||
if valid_token is not None and valid_token.object_permission_id is not None:
|
||||
key_object_permission = await get_object_permission(
|
||||
object_permission_id=valid_token.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=getattr(valid_token, "parent_otel_span", None),
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if key_object_permission is not None:
|
||||
_can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name=search_tool_name,
|
||||
object_permissions=key_object_permission,
|
||||
)
|
||||
|
||||
# Check team-level permissions
|
||||
team_object_permission_id = (
|
||||
getattr(valid_token, "team_object_permission_id", None)
|
||||
if valid_token
|
||||
else None
|
||||
)
|
||||
if team_object_permission_id is not None:
|
||||
team_object_permission = await get_object_permission(
|
||||
object_permission_id=team_object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=getattr(valid_token, "parent_otel_span", None),
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if team_object_permission is not None:
|
||||
_can_object_call_search_tools(
|
||||
object_type="team",
|
||||
search_tool_name=search_tool_name,
|
||||
object_permissions=team_object_permission,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_search_tools_wildcard(
|
||||
search_tools: Optional[List[str]],
|
||||
) -> Optional[List[str]]:
|
||||
"""Treat ["*"] (the migration default) as None (no restriction)."""
|
||||
if search_tools is not None and "*" in search_tools:
|
||||
return None
|
||||
return search_tools
|
||||
|
||||
|
||||
async def get_allowed_search_tool_names(
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Compute the intersection of key-level and team-level search tool permissions.
|
||||
|
||||
Returns:
|
||||
None → no restriction (all tools accessible)
|
||||
list → only those tool names are accessible (may be empty = none)
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
return None
|
||||
|
||||
key_allowed: Optional[List[str]] = None
|
||||
team_allowed: Optional[List[str]] = None
|
||||
|
||||
# Key-level permissions (via cached helper)
|
||||
if user_api_key_dict.object_permission_id is not None:
|
||||
key_perm = await get_object_permission(
|
||||
object_permission_id=user_api_key_dict.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None),
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if key_perm is not None:
|
||||
key_allowed = _normalize_search_tools_wildcard(key_perm.search_tools)
|
||||
|
||||
# Team-level permissions (via cached helper)
|
||||
team_perm_id = getattr(user_api_key_dict, "team_object_permission_id", None)
|
||||
if team_perm_id is not None:
|
||||
team_perm = await get_object_permission(
|
||||
object_permission_id=team_perm_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None),
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if team_perm is not None:
|
||||
team_allowed = _normalize_search_tools_wildcard(team_perm.search_tools)
|
||||
|
||||
# Combine: both None → None (no restriction)
|
||||
# One set → use that set
|
||||
# Both set → intersection
|
||||
if key_allowed is None and team_allowed is None:
|
||||
return None
|
||||
if key_allowed is None:
|
||||
return team_allowed
|
||||
if team_allowed is None:
|
||||
return key_allowed
|
||||
# Both are set - return the intersection
|
||||
return list(set(key_allowed) & set(team_allowed))
|
||||
|
|
|
|||
59
litellm/proxy/auth/permissions.py
Normal file
59
litellm/proxy/auth/permissions.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Granular permission strings for the LiteLLM proxy.
|
||||
|
||||
Permission format: `resource:action`
|
||||
|
||||
This module defines the valid permission strings that can be granted to
|
||||
team members via `extra_permissions`. It is the first step toward a full
|
||||
Permission Strings RBAC system (custom roles, org intersection, denied_permissions).
|
||||
|
||||
New resource permissions should be added here as enums and included in
|
||||
VALID_PERMISSIONS so that the validation in team_member_update rejects typos.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class MCPPermission(str, Enum):
|
||||
"""Permissions for MCP server management."""
|
||||
|
||||
READ = "mcp:read"
|
||||
CREATE = "mcp:create"
|
||||
UPDATE = "mcp:update"
|
||||
DELETE = "mcp:delete"
|
||||
|
||||
|
||||
# All known permission strings — used for validation when granting permissions.
|
||||
# Grows as more resource permissions are added (keys:create, teams:create, etc.)
|
||||
VALID_PERMISSIONS: set = {p.value for p in MCPPermission}
|
||||
|
||||
|
||||
def get_available_permissions() -> List[Dict[str, str]]:
|
||||
"""
|
||||
Return the list of valid permission strings with labels, grouped by resource.
|
||||
|
||||
Used by the GET /team/available_permissions endpoint for UI dropdowns.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"value": MCPPermission.READ.value,
|
||||
"label": "View MCP servers",
|
||||
"resource": "mcp",
|
||||
},
|
||||
{
|
||||
"value": MCPPermission.CREATE.value,
|
||||
"label": "Create MCP servers",
|
||||
"resource": "mcp",
|
||||
},
|
||||
{
|
||||
"value": MCPPermission.UPDATE.value,
|
||||
"label": "Edit MCP servers",
|
||||
"resource": "mcp",
|
||||
},
|
||||
{
|
||||
"value": MCPPermission.DELETE.value,
|
||||
"label": "Delete MCP servers",
|
||||
"resource": "mcp",
|
||||
},
|
||||
]
|
||||
|
|
@ -11,6 +11,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
NewProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
UserAPIKeyAuth,
|
||||
|
|
@ -41,6 +42,70 @@ def _is_user_team_admin(
|
|||
return False
|
||||
|
||||
|
||||
def _find_member_in_team(
|
||||
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
|
||||
) -> Optional[Member]:
|
||||
"""Find and return the Member object for the given user in the team, or None.
|
||||
|
||||
Matches by user_id first, then falls back to user_email for email-only members.
|
||||
"""
|
||||
for member in team_obj.members_with_roles:
|
||||
if (
|
||||
user_api_key_dict.user_id
|
||||
and member.user_id is not None
|
||||
and member.user_id == user_api_key_dict.user_id
|
||||
):
|
||||
return member
|
||||
# Fallback: match by email for email-only members
|
||||
if user_api_key_dict.user_email:
|
||||
for member in team_obj.members_with_roles:
|
||||
if (
|
||||
member.user_email is not None
|
||||
and member.user_email == user_api_key_dict.user_email
|
||||
):
|
||||
return member
|
||||
return None
|
||||
|
||||
|
||||
def check_member_permission(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
team_obj: LiteLLM_TeamTable,
|
||||
required_permission: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has a specific permission for a team.
|
||||
|
||||
Resolution order:
|
||||
1. Proxy admin → True
|
||||
2. Team admin → True
|
||||
3. Member with required_permission in extra_permissions → True
|
||||
4. Otherwise → False
|
||||
|
||||
This is the minimal permission check for the first phase of granular RBAC.
|
||||
When the full RBAC engine ships, this function will be replaced by
|
||||
check_permission() in permissions.py with role resolution, org intersection,
|
||||
and denied_permissions support.
|
||||
"""
|
||||
# 1. Proxy admin
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
|
||||
return True
|
||||
|
||||
# Find member in team
|
||||
member = _find_member_in_team(user_api_key_dict, team_obj)
|
||||
if member is None:
|
||||
return False
|
||||
|
||||
# 2. Team admin
|
||||
if member.role == "admin":
|
||||
return True
|
||||
|
||||
# 3. Check extra_permissions
|
||||
if member.extra_permissions and required_permission in member.extra_permissions:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _is_user_org_admin_for_team(
|
||||
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
|
||||
) -> bool:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ if MCP_AVAILABLE:
|
|||
build_effective_auth_contexts,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
LiteLLM_MCPServerTable,
|
||||
LitellmUserRoles,
|
||||
MakeMCPServersPublicRequest,
|
||||
|
|
@ -130,7 +131,14 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
||||
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_view,
|
||||
check_member_permission,
|
||||
)
|
||||
from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
add_mcp_server_to_team,
|
||||
remove_mcp_server_from_team,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
|
||||
from litellm.types.mcp import MCPCredentials
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
|
@ -914,6 +922,19 @@ if MCP_AVAILABLE:
|
|||
validate_and_normalize_mcp_server_payload(payload)
|
||||
_validate_mcp_required_fields(payload)
|
||||
|
||||
# Guard against virtual UI-session team
|
||||
register_team_id = user_api_key_dict.team_id
|
||||
if register_team_id == UI_TEAM_ID:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Cannot register MCP servers with the dashboard session team. Use a real team-scoped key."
|
||||
},
|
||||
)
|
||||
|
||||
# Set ownership — registered servers belong to the submitter's team
|
||||
payload.team_id = register_team_id
|
||||
|
||||
payload.approval_status = MCPApprovalStatus.pending_review
|
||||
payload.submitted_by = user_api_key_dict.user_id
|
||||
payload.submitted_at = datetime.now(timezone.utc)
|
||||
|
|
@ -1008,6 +1029,18 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
|
||||
# Grant the owning team access to the now-approved server
|
||||
approved_server = await get_mcp_server(prisma_client, server_id)
|
||||
if approved_server and approved_server.team_id:
|
||||
try:
|
||||
await add_mcp_server_to_team(
|
||||
prisma_client, approved_server.team_id, server_id
|
||||
)
|
||||
except ValueError:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Could not add approved server {server_id} to team {approved_server.team_id}: team not found"
|
||||
)
|
||||
|
||||
return _redact_mcp_credentials(approved)
|
||||
|
||||
@router.put(
|
||||
|
|
@ -1206,14 +1239,39 @@ if MCP_AVAILABLE:
|
|||
# Validate and normalize payload fields
|
||||
validate_and_normalize_mcp_server_payload(payload)
|
||||
|
||||
# AuthZ - restrict only proxy admins to create mcp servers
|
||||
# AuthZ - proxy admins, team admins, or members with mcp:create permission
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
team_obj = None
|
||||
team_id = payload.team_id or user_api_key_dict.team_id
|
||||
# litellm-dashboard is a virtual UI-session team, not a real DB team
|
||||
if team_id == UI_TEAM_ID:
|
||||
team_id = None
|
||||
|
||||
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN."
|
||||
},
|
||||
if not team_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "team_id is required for non-proxy-admin users to create MCP servers."
|
||||
},
|
||||
)
|
||||
team_obj = await get_team_object(
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
if not check_member_permission(
|
||||
user_api_key_dict, team_obj, "mcp:create"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "User does not have permission to create MCP servers for this team. "
|
||||
"Requires team admin role or 'mcp:create' permission."
|
||||
},
|
||||
)
|
||||
|
||||
# Block reserved special server IDs
|
||||
if (
|
||||
|
|
@ -1240,6 +1298,9 @@ if MCP_AVAILABLE:
|
|||
|
||||
# TODO: audit log for create
|
||||
|
||||
# Set ownership team_id on the server record
|
||||
payload.team_id = team_id
|
||||
|
||||
# Admin-created servers are always active — clear any submission lifecycle
|
||||
# fields the caller may have provided to prevent fake entries appearing in
|
||||
# the submissions queue.
|
||||
|
|
@ -1254,16 +1315,32 @@ if MCP_AVAILABLE:
|
|||
payload,
|
||||
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
|
||||
)
|
||||
await global_mcp_server_manager.add_server(new_mcp_server)
|
||||
|
||||
# Ensure registry is up to date by reloading from database
|
||||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error creating mcp server: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"error": f"Error creating mcp server: {str(e)}"},
|
||||
)
|
||||
|
||||
# Auto-assign server to team's ObjectPermissionTable if team-scoped.
|
||||
# Must happen before the server manager reload so the registry
|
||||
# reflects team membership immediately.
|
||||
if team_id and new_mcp_server.server_id:
|
||||
try:
|
||||
await add_mcp_server_to_team(
|
||||
prisma_client, team_id, new_mcp_server.server_id
|
||||
)
|
||||
except ValueError as e:
|
||||
# Team not found — surface as 400 so caller knows
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
await global_mcp_server_manager.add_server(new_mcp_server)
|
||||
# Ensure registry is up to date by reloading from database
|
||||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
|
||||
return _redact_mcp_credentials(new_mcp_server)
|
||||
|
||||
@router.post(
|
||||
|
|
@ -1452,7 +1529,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
@router.delete(
|
||||
"/server/{server_id}",
|
||||
description="Allows deleting mcp serves in the db",
|
||||
description="Allows deleting mcp servers in the db",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_class=JSONResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
|
|
@ -1480,16 +1557,55 @@ if MCP_AVAILABLE:
|
|||
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
|
||||
# Authz - restrict only admins to delete mcp servers
|
||||
# AuthZ - proxy admins, team admins, or members with mcp:delete permission
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
team_id: Optional[str] = None
|
||||
|
||||
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "Call not allowed to delete MCP server. User is not a proxy admin. route={}".format(
|
||||
"DELETE /v1/mcp/server"
|
||||
)
|
||||
},
|
||||
# Look up the server's owning team directly
|
||||
mcp_server = await get_mcp_server(prisma_client, server_id)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"error": f"MCP Server not found, server_id={server_id}"
|
||||
},
|
||||
)
|
||||
|
||||
team_id = mcp_server.team_id
|
||||
if not team_id:
|
||||
# Global server (team_id=NULL) — only proxy admins can manage
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "This is a global MCP server. Only proxy admins can delete it."
|
||||
},
|
||||
)
|
||||
|
||||
team_obj = await get_team_object(
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
if not check_member_permission(
|
||||
user_api_key_dict, team_obj, "mcp:delete"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "User does not have permission to delete this MCP server. "
|
||||
"Requires team admin role or 'mcp:delete' permission in the server's owning team."
|
||||
},
|
||||
)
|
||||
|
||||
# For proxy admins, look up the server's team_id before deleting
|
||||
# so we can clean up ObjectPermissionTable
|
||||
if not team_id and LitellmUserRoles.PROXY_ADMIN == user_api_key_dict.user_role:
|
||||
existing_server = await get_mcp_server(prisma_client, server_id)
|
||||
if existing_server:
|
||||
team_id = existing_server.team_id
|
||||
|
||||
# try to delete the mcp server
|
||||
mcp_server_record_deleted = await delete_mcp_server(prisma_client, server_id)
|
||||
|
|
@ -1504,16 +1620,20 @@ if MCP_AVAILABLE:
|
|||
# Ensure registry is up to date by reloading from database
|
||||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
|
||||
# Remove server from team's ObjectPermissionTable
|
||||
if team_id:
|
||||
try:
|
||||
await remove_mcp_server_from_team(prisma_client, team_id, server_id)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Failed to remove server {server_id} from team {team_id} permissions: {e}. "
|
||||
"Server was deleted but team's ObjectPermissionTable may contain a stale entry."
|
||||
)
|
||||
|
||||
# TODO: Enterprise: Finish audit log trail
|
||||
if litellm.store_audit_logs:
|
||||
pass
|
||||
|
||||
# TODO: Delete from virtual keys
|
||||
|
||||
# TODO: Delete from teams
|
||||
|
||||
# Update from global mcp store
|
||||
|
||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@router.post(
|
||||
|
|
@ -1774,7 +1894,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
@router.put(
|
||||
"/server",
|
||||
description="Allows deleting mcp serves in the db",
|
||||
description="Allows updating mcp servers in the db",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=LiteLLM_MCPServerTable,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
|
|
@ -1805,16 +1925,67 @@ if MCP_AVAILABLE:
|
|||
# Validate and normalize payload fields
|
||||
validate_and_normalize_mcp_server_payload(payload)
|
||||
|
||||
# Authz - restrict only admins to delete mcp servers
|
||||
# AuthZ - proxy admins, team admins, or members with mcp:update permission
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "Call not allowed to update MCP server. User is not a proxy admin. route={}".format(
|
||||
"PUT /v1/mcp/server"
|
||||
)
|
||||
},
|
||||
mcp_server = await get_mcp_server(prisma_client, payload.server_id)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"error": f"MCP Server not found, server_id={payload.server_id}"
|
||||
},
|
||||
)
|
||||
|
||||
owner_team_id = mcp_server.team_id
|
||||
if not owner_team_id:
|
||||
# Global server (team_id=NULL) — only proxy admins can manage
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "This is a global MCP server. Only proxy admins can update it."
|
||||
},
|
||||
)
|
||||
|
||||
team_obj = await get_team_object(
|
||||
team_id=owner_team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
if not check_member_permission(
|
||||
user_api_key_dict, team_obj, "mcp:update"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "User does not have permission to update this MCP server. "
|
||||
"Requires team admin role or 'mcp:update' permission in the server's owning team."
|
||||
},
|
||||
)
|
||||
|
||||
# Non-admins cannot change ownership at all
|
||||
if "team_id" in payload.model_fields_set:
|
||||
if payload.team_id != owner_team_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": "Only proxy admins can change MCP server ownership."
|
||||
},
|
||||
)
|
||||
# Preserve existing team_id — non-admins can't change it
|
||||
payload.team_id = owner_team_id
|
||||
|
||||
# For proxy admins: detect if ownership is changing
|
||||
is_admin = LitellmUserRoles.PROXY_ADMIN == user_api_key_dict.user_role
|
||||
team_id_explicitly_set = "team_id" in payload.model_fields_set
|
||||
old_team_id: Optional[str] = None
|
||||
|
||||
if is_admin and team_id_explicitly_set:
|
||||
existing_server = await get_mcp_server(prisma_client, payload.server_id)
|
||||
if existing_server:
|
||||
old_team_id = existing_server.team_id
|
||||
|
||||
# try to update the mcp server
|
||||
mcp_server_record_updated = await update_mcp_server(
|
||||
|
|
@ -1830,6 +2001,42 @@ if MCP_AVAILABLE:
|
|||
"error": f"MCP Server not found, passed server_id={payload.server_id}"
|
||||
},
|
||||
)
|
||||
# Handle explicit team_id changes (including clearing to null/global)
|
||||
# Must happen before registry reload so the cache reflects the new state.
|
||||
if is_admin and team_id_explicitly_set:
|
||||
new_team_id = payload.team_id # could be a team ID or None (global)
|
||||
|
||||
# Direct DB update for team_id — exclude_none in _prepare_mcp_server_data
|
||||
# skips None, so we must write it explicitly when clearing to global
|
||||
if new_team_id is None:
|
||||
await prisma_client.db.litellm_mcpservertable.update(
|
||||
where={"server_id": payload.server_id},
|
||||
data={"team_id": None},
|
||||
)
|
||||
if mcp_server_record_updated is not None:
|
||||
mcp_server_record_updated.team_id = None
|
||||
|
||||
# Sync ObjectPermissionTable
|
||||
if old_team_id != new_team_id:
|
||||
if old_team_id:
|
||||
try:
|
||||
await remove_mcp_server_from_team(
|
||||
prisma_client, old_team_id, payload.server_id
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Failed to remove server {payload.server_id} from old team {old_team_id}: {e}"
|
||||
)
|
||||
if new_team_id:
|
||||
try:
|
||||
await add_mcp_server_to_team(
|
||||
prisma_client, new_team_id, payload.server_id
|
||||
)
|
||||
except ValueError as e:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Failed to add server {payload.server_id} to new team {new_team_id}: {e}"
|
||||
)
|
||||
|
||||
await global_mcp_server_manager.update_server(mcp_server_record_updated)
|
||||
|
||||
# Ensure registry is up to date by reloading from database
|
||||
|
|
@ -1958,16 +2165,6 @@ if MCP_AVAILABLE:
|
|||
|
||||
Used by the UI to show a discovery grid when adding new MCP servers.
|
||||
"""
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Only proxy admins can access MCP discovery. Your role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
registry = _load_mcp_registry()
|
||||
servers = registry.get("servers", [])
|
||||
|
||||
|
|
|
|||
|
|
@ -1001,7 +1001,7 @@ async def new_team( # noqa: PLR0915
|
|||
|
||||
team_row: LiteLLM_TeamTable = await prisma_client.db.litellm_teamtable.create(
|
||||
data=complete_team_data_dict,
|
||||
include={"litellm_model_table": True}, # type: ignore
|
||||
include={"litellm_model_table": True, "object_permission": True}, # type: ignore
|
||||
)
|
||||
|
||||
## ADD TEAM ID TO USER TABLE ##
|
||||
|
|
@ -1550,7 +1550,7 @@ async def update_team( # noqa: PLR0915
|
|||
] = await prisma_client.db.litellm_teamtable.update(
|
||||
where={"team_id": data.team_id},
|
||||
data=updated_kv,
|
||||
include={"litellm_model_table": True}, # type: ignore
|
||||
include={"litellm_model_table": True, "object_permission": True}, # type: ignore
|
||||
)
|
||||
|
||||
if team_row is None or team_row.team_id is None:
|
||||
|
|
@ -2424,6 +2424,20 @@ async def team_member_update(
|
|||
identified_budget_id = tm.budget_id
|
||||
break
|
||||
|
||||
### Validate extra_permissions BEFORE any DB writes
|
||||
if data.extra_permissions is not None:
|
||||
from litellm.proxy.auth.permissions import VALID_PERMISSIONS
|
||||
|
||||
invalid_perms = set(data.extra_permissions) - VALID_PERMISSIONS
|
||||
if invalid_perms:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Invalid permission strings: {sorted(invalid_perms)}. "
|
||||
f"Valid permissions: {sorted(VALID_PERMISSIONS)}"
|
||||
},
|
||||
)
|
||||
|
||||
### upsert new budget
|
||||
async with prisma_client.db.tx() as tx:
|
||||
await _upsert_budget_and_membership(
|
||||
|
|
@ -2437,39 +2451,69 @@ async def team_member_update(
|
|||
rpm_limit=data.rpm_limit,
|
||||
)
|
||||
|
||||
### update team member role
|
||||
if data.role is not None:
|
||||
team_members: List[Member] = []
|
||||
### Apply role and extra_permissions updates in-memory, then do a single DB write
|
||||
members_changed = data.role is not None or data.extra_permissions is not None
|
||||
if members_changed:
|
||||
updated_members: List[Member] = []
|
||||
for member in team_table.members_with_roles:
|
||||
if member.user_id == received_user_id:
|
||||
team_members.append(
|
||||
updated_members.append(
|
||||
Member(
|
||||
user_id=member.user_id,
|
||||
role=data.role,
|
||||
role=data.role if data.role is not None else member.role,
|
||||
user_email=data.user_email or member.user_email,
|
||||
extra_permissions=(
|
||||
data.extra_permissions
|
||||
if data.extra_permissions is not None
|
||||
else member.extra_permissions
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
team_members.append(member)
|
||||
updated_members.append(member)
|
||||
|
||||
team_table.members_with_roles = team_members
|
||||
team_table.members_with_roles = updated_members
|
||||
|
||||
_db_team_members: List[dict] = [m.model_dump() for m in team_members]
|
||||
_db_team_members: List[dict] = [m.model_dump() for m in updated_members]
|
||||
await prisma_client.db.litellm_teamtable.update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"members_with_roles": json.dumps(_db_team_members)}, # type: ignore
|
||||
)
|
||||
|
||||
# Invalidate team cache so changes take effect immediately
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
user_api_key_cache.delete_cache(key="team_id:{}".format(data.team_id))
|
||||
|
||||
return TeamMemberUpdateResponse(
|
||||
team_id=data.team_id,
|
||||
user_id=received_user_id,
|
||||
user_email=data.user_email,
|
||||
max_budget_in_team=data.max_budget_in_team,
|
||||
extra_permissions=data.extra_permissions,
|
||||
tpm_limit=data.tpm_limit,
|
||||
rpm_limit=data.rpm_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/available_permissions",
|
||||
tags=["team management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_available_permissions(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
List all valid permission strings that can be granted to team members.
|
||||
|
||||
Used by the UI to populate permission dropdowns when editing team member permissions.
|
||||
"""
|
||||
from litellm.proxy.auth.permissions import get_available_permissions
|
||||
|
||||
return get_available_permissions()
|
||||
|
||||
|
||||
def _create_results_from_response(
|
||||
members: List[Member],
|
||||
response: TeamAddMemberResponse,
|
||||
|
|
@ -3546,6 +3590,7 @@ async def list_team_v2(
|
|||
skip=skip,
|
||||
take=page_size,
|
||||
order=order_by if order_by else {"created_at": "desc"}, # Default sort
|
||||
include={"object_permission": True},
|
||||
)
|
||||
# Get total count for pagination
|
||||
total_count = await prisma_client.db.litellm_teamtable.count(
|
||||
|
|
@ -3624,7 +3669,7 @@ async def _authorize_and_filter_teams(
|
|||
# Org admin: query DB for teams in their orgs
|
||||
org_teams = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"organization_id": {"in": allowed_org_ids}},
|
||||
include={"litellm_model_table": True},
|
||||
include={"litellm_model_table": True, "object_permission": True},
|
||||
)
|
||||
if not user_id:
|
||||
return list(org_teams)
|
||||
|
|
@ -3634,7 +3679,7 @@ async def _authorize_and_filter_teams(
|
|||
# Prisma doesn't support filtering JSON array fields, so we fetch by membership separately
|
||||
member_teams = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"not_in": list(seen_team_ids)}} if seen_team_ids else {},
|
||||
include={"litellm_model_table": True},
|
||||
include={"litellm_model_table": True, "object_permission": True},
|
||||
)
|
||||
for team in member_teams:
|
||||
if team.members_with_roles and any(
|
||||
|
|
@ -3645,7 +3690,7 @@ async def _authorize_and_filter_teams(
|
|||
elif user_id:
|
||||
# Regular user: fetch all and filter by membership (Prisma can't filter JSON arrays)
|
||||
response = await prisma_client.db.litellm_teamtable.find_many(
|
||||
include={"litellm_model_table": True}
|
||||
include={"litellm_model_table": True, "object_permission": True}
|
||||
)
|
||||
return [
|
||||
team
|
||||
|
|
@ -3657,7 +3702,7 @@ async def _authorize_and_filter_teams(
|
|||
# Proxy admin: all teams
|
||||
return list(
|
||||
await prisma_client.db.litellm_teamtable.find_many(
|
||||
include={"litellm_model_table": True}
|
||||
include={"litellm_model_table": True, "object_permission": True}
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -364,3 +364,112 @@ async def validate_key_mcp_servers_against_team(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": detail},
|
||||
)
|
||||
|
||||
|
||||
def _invalidate_team_cache(
|
||||
team_id: str, object_permission_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""Invalidate the cached team object and its ObjectPermissionTable so subsequent reads see updated data."""
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
user_api_key_cache.delete_cache(key="team_id:{}".format(team_id))
|
||||
if object_permission_id:
|
||||
user_api_key_cache.delete_cache(
|
||||
key="object_permission_id:{}".format(object_permission_id)
|
||||
)
|
||||
|
||||
|
||||
async def add_mcp_server_to_team(
|
||||
prisma_client: PrismaClient, team_id: str, server_id: str
|
||||
) -> None:
|
||||
"""
|
||||
Add an MCP server ID to a team's ObjectPermissionTable.mcp_servers.
|
||||
|
||||
If the team has no ObjectPermissionTable yet, one is created.
|
||||
Uses a DB transaction to prevent race conditions when multiple
|
||||
concurrent calls try to initialize the ObjectPermissionTable.
|
||||
"""
|
||||
async with prisma_client.db.tx() as tx:
|
||||
team = await tx.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
if team is None:
|
||||
raise ValueError(f"Team {team_id} not found")
|
||||
|
||||
object_permission_id = team.object_permission_id or str(uuid.uuid4())
|
||||
|
||||
# Get existing object permission or start fresh
|
||||
existing_mcp_servers: List[str] = []
|
||||
if team.object_permission_id:
|
||||
existing_perm = (
|
||||
await tx.litellm_objectpermissiontable.find_unique(
|
||||
where={"object_permission_id": team.object_permission_id}
|
||||
)
|
||||
)
|
||||
if existing_perm and existing_perm.mcp_servers:
|
||||
existing_mcp_servers = list(existing_perm.mcp_servers)
|
||||
|
||||
# Add server if not already present
|
||||
if server_id not in existing_mcp_servers:
|
||||
existing_mcp_servers.append(server_id)
|
||||
|
||||
# Upsert the ObjectPermissionTable
|
||||
await tx.litellm_objectpermissiontable.upsert(
|
||||
where={"object_permission_id": object_permission_id},
|
||||
data={
|
||||
"create": {
|
||||
"object_permission_id": object_permission_id,
|
||||
"mcp_servers": existing_mcp_servers,
|
||||
},
|
||||
"update": {
|
||||
"mcp_servers": existing_mcp_servers,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Link the team to the ObjectPermissionTable if not already linked
|
||||
if not team.object_permission_id:
|
||||
await tx.litellm_teamtable.update(
|
||||
where={"team_id": team_id},
|
||||
data={"object_permission_id": object_permission_id},
|
||||
)
|
||||
|
||||
# Invalidate team cache so the updated mcp_servers list is visible immediately
|
||||
_invalidate_team_cache(team_id, object_permission_id)
|
||||
|
||||
|
||||
async def remove_mcp_server_from_team(
|
||||
prisma_client: PrismaClient, team_id: str, server_id: str
|
||||
) -> None:
|
||||
"""
|
||||
Remove an MCP server ID from a team's ObjectPermissionTable.mcp_servers.
|
||||
|
||||
No-op if the team has no ObjectPermissionTable or the server isn't in the list.
|
||||
Uses a DB transaction for consistency.
|
||||
"""
|
||||
async with prisma_client.db.tx() as tx:
|
||||
team = await tx.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
if team is None or not team.object_permission_id:
|
||||
return
|
||||
|
||||
existing_perm = (
|
||||
await tx.litellm_objectpermissiontable.find_unique(
|
||||
where={"object_permission_id": team.object_permission_id}
|
||||
)
|
||||
)
|
||||
if existing_perm is None or not existing_perm.mcp_servers:
|
||||
return
|
||||
|
||||
updated_servers = [s for s in existing_perm.mcp_servers if s != server_id]
|
||||
if len(updated_servers) != len(existing_perm.mcp_servers):
|
||||
await tx.litellm_objectpermissiontable.update(
|
||||
where={"object_permission_id": team.object_permission_id},
|
||||
data={"mcp_servers": updated_servers},
|
||||
)
|
||||
|
||||
# Invalidate team cache so the updated mcp_servers list is visible immediately
|
||||
_invalidate_team_cache(
|
||||
team_id, team.object_permission_id if team else None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
|
||||
agents String[] @default([])
|
||||
agent_access_groups String[] @default([])
|
||||
models String[] @default([])
|
||||
|
|
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
team_id String?
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from fastapi.responses import ORJSONResponse
|
|||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import get_allowed_search_tool_names
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
|
||||
|
|
@ -163,6 +164,24 @@ async def search(
|
|||
data["metadata"] = {}
|
||||
data["metadata"]["model_group"] = search_tool_name_value
|
||||
|
||||
# Access control check for search tools
|
||||
resolved_search_tool_name = data.get("search_tool_name")
|
||||
if resolved_search_tool_name:
|
||||
from litellm.proxy.auth.auth_checks import search_tool_access_check
|
||||
|
||||
await search_tool_access_check(
|
||||
search_tool_name=resolved_search_tool_name,
|
||||
valid_token=user_api_key_dict,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "search_tool_name is required. Provide it in the URL path "
|
||||
"(/v1/search/{search_tool_name}) or in the request body."
|
||||
},
|
||||
)
|
||||
|
||||
# Process request using ProxyBaseLLMRequestProcessing
|
||||
processor = ProxyBaseLLMRequestProcessing(data=data)
|
||||
try:
|
||||
|
|
@ -258,6 +277,15 @@ async def list_search_tools(
|
|||
|
||||
search_tools_list.append(tool_info)
|
||||
|
||||
# Filter search tools based on user's permissions
|
||||
allowed_names = await get_allowed_search_tool_names(user_api_key_dict)
|
||||
if allowed_names is not None:
|
||||
search_tools_list = [
|
||||
tool
|
||||
for tool in search_tools_list
|
||||
if tool.get("search_tool_name") in allowed_names
|
||||
]
|
||||
|
||||
return {"object": "list", "data": search_tools_list}
|
||||
except Exception as e:
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
CRUD ENDPOINTS FOR SEARCH TOOLS
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.auth.auth_checks import get_allowed_search_tool_names
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
|
||||
from litellm.types.search import (
|
||||
ListSearchToolsResponse,
|
||||
|
|
@ -46,7 +47,9 @@ def _convert_datetime_to_str(value: Union[datetime, str, None]) -> Union[str, No
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ListSearchToolsResponse,
|
||||
)
|
||||
async def list_search_tools():
|
||||
async def list_search_tools(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
List all search tools that are available in the database and config file.
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ async def list_search_tools():
|
|||
search_tool_id=None,
|
||||
search_tool_name=tool_name,
|
||||
litellm_params=masked_litellm_params_dict,
|
||||
search_tool_info=search_tool.get("search_tool_info"),
|
||||
search_tool_info=cast(Optional[dict], search_tool.get("search_tool_info")),
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
is_from_config=True,
|
||||
|
|
@ -141,8 +144,8 @@ async def list_search_tools():
|
|||
if tool.get("search_tool_name") not in db_tool_names
|
||||
]
|
||||
|
||||
for search_tool in search_tools_from_db:
|
||||
litellm_params_dict = dict(search_tool.get("litellm_params", {}))
|
||||
for db_tool in search_tools_from_db:
|
||||
litellm_params_dict = dict(db_tool.get("litellm_params", {}))
|
||||
masked_litellm_params_dict = _get_masked_values(
|
||||
litellm_params_dict,
|
||||
unmasked_length=4,
|
||||
|
|
@ -151,16 +154,25 @@ async def list_search_tools():
|
|||
|
||||
search_tool_configs.append(
|
||||
SearchToolInfoResponse(
|
||||
search_tool_id=search_tool.get("search_tool_id"),
|
||||
search_tool_name=search_tool.get("search_tool_name", ""),
|
||||
search_tool_id=cast(Optional[str], db_tool.get("search_tool_id")),
|
||||
search_tool_name=db_tool.get("search_tool_name", ""),
|
||||
litellm_params=masked_litellm_params_dict,
|
||||
search_tool_info=search_tool.get("search_tool_info"),
|
||||
created_at=_convert_datetime_to_str(search_tool.get("created_at")),
|
||||
updated_at=_convert_datetime_to_str(search_tool.get("updated_at")),
|
||||
search_tool_info=cast(Optional[dict], db_tool.get("search_tool_info")),
|
||||
created_at=_convert_datetime_to_str(cast(Optional[Union[datetime, str]], db_tool.get("created_at"))),
|
||||
updated_at=_convert_datetime_to_str(cast(Optional[Union[datetime, str]], db_tool.get("updated_at"))),
|
||||
is_from_config=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Filter based on caller's key/team permissions
|
||||
allowed_names = await get_allowed_search_tool_names(user_api_key_dict)
|
||||
if allowed_names is not None:
|
||||
search_tool_configs = [
|
||||
tool
|
||||
for tool in search_tool_configs
|
||||
if tool.get("search_tool_name") in allowed_names
|
||||
]
|
||||
|
||||
return ListSearchToolsResponse(search_tools=search_tool_configs)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error getting search tools: {e}")
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class MCPServer(BaseModel):
|
|||
is_byok: bool = False
|
||||
byok_description: List[str] = []
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
# OAuth2 flow type. Defaults to None (interactive / authorization_code).
|
||||
|
|
|
|||
73
poetry.lock
generated
73
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "a2a-sdk"
|
||||
|
|
@ -7,11 +7,11 @@ description = "A2A Python SDK"
|
|||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385"},
|
||||
{file = "a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = ">=1.26.0"
|
||||
|
|
@ -385,7 +385,6 @@ files = [
|
|||
{file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"},
|
||||
{file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
requests = ">=2.21.0"
|
||||
|
|
@ -406,7 +405,6 @@ files = [
|
|||
{file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"},
|
||||
{file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
azure-core = ">=1.31.0"
|
||||
|
|
@ -600,7 +598,7 @@ files = [
|
|||
{file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"},
|
||||
{file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
|
|
@ -707,7 +705,7 @@ files = [
|
|||
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
|
||||
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
|
||||
]
|
||||
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
|
||||
markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
||||
|
|
@ -1057,7 +1055,6 @@ files = [
|
|||
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
|
||||
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
|
||||
|
|
@ -1840,11 +1837,11 @@ description = "Google API client core library"
|
|||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.14\""
|
||||
files = [
|
||||
{file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"},
|
||||
{file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
|
|
@ -1872,7 +1869,7 @@ files = [
|
|||
{file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"},
|
||||
{file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"},
|
||||
]
|
||||
markers = {main = "python_version < \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
|
||||
markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version < \"3.14\"", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
|
|
@ -1909,7 +1906,7 @@ files = [
|
|||
{file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"},
|
||||
{file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
cachetools = ">=2.0.0,<7.0"
|
||||
|
|
@ -2081,11 +2078,11 @@ files = [
|
|||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0"
|
||||
grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0"
|
||||
proto-plus = ">=1.22.3,<2.0.0.dev0"
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev"
|
||||
proto-plus = ">=1.22.3,<2.0.0dev"
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
|
|
@ -2267,7 +2264,7 @@ files = [
|
|||
{file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"},
|
||||
{file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}
|
||||
|
|
@ -2676,11 +2673,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
|||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
|
||||
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "huey"
|
||||
|
|
@ -3045,7 +3042,7 @@ files = [
|
|||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
|
|
@ -3222,15 +3219,15 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.58"
|
||||
version = "0.4.59"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
optional = true
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"proxy\""
|
||||
files = [
|
||||
{file = "litellm_proxy_extras-0.4.58-py3-none-any.whl", hash = "sha256:8863e70de833c0e35119a1cbbf583619bdebe52222efd5654586519175ba403b"},
|
||||
{file = "litellm_proxy_extras-0.4.58.tar.gz", hash = "sha256:84a67483329eced8be4fc61c4e43f117287aa4e3deeb8ddf8fe8cdc9a8508836"},
|
||||
{file = "litellm_proxy_extras-0.4.59-py3-none-any.whl", hash = "sha256:537080d1b2de32eafc386202cf38c789a1da7f6e75b206fbeb83b935330e8b79"},
|
||||
{file = "litellm_proxy_extras-0.4.59.tar.gz", hash = "sha256:11ed5f7f71e48b27fdfc030c86d933bf88f7ad09f0743eafde084390b6bed26f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3716,7 +3713,6 @@ files = [
|
|||
{file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"},
|
||||
{file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=2.5,<49"
|
||||
|
|
@ -3737,7 +3733,6 @@ files = [
|
|||
{file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"},
|
||||
{file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
msal = ">=1.29,<2"
|
||||
|
|
@ -3988,7 +3983,6 @@ files = [
|
|||
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
|
|
@ -4111,7 +4105,7 @@ files = [
|
|||
{file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"},
|
||||
{file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
importlib-metadata = ">=6.0,<8.8.0"
|
||||
|
|
@ -4226,7 +4220,7 @@ files = [
|
|||
{file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"},
|
||||
{file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.39.1"
|
||||
|
|
@ -4244,7 +4238,7 @@ files = [
|
|||
{file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"},
|
||||
{file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.39.1"
|
||||
|
|
@ -4743,7 +4737,6 @@ files = [
|
|||
{file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"},
|
||||
{file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.1.2"
|
||||
|
|
@ -4917,7 +4910,7 @@ files = [
|
|||
{file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"},
|
||||
{file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
protobuf = ">=3.19.0,<7.0.0"
|
||||
|
|
@ -4945,7 +4938,7 @@ files = [
|
|||
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
|
||||
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
|
|
@ -5105,7 +5098,7 @@ files = [
|
|||
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
|
||||
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
|
|
@ -5118,7 +5111,7 @@ files = [
|
|||
{file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
|
||||
{file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.6.1,<0.7.0"
|
||||
|
|
@ -5146,7 +5139,7 @@ files = [
|
|||
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
|
||||
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
|
||||
]
|
||||
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
|
||||
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
|
|
@ -5369,7 +5362,6 @@ files = [
|
|||
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
|
||||
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
|
||||
]
|
||||
markers = {main = "(python_version <= \"3.13\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"proxy\")"}
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
|
||||
|
|
@ -6313,7 +6305,7 @@ files = [
|
|||
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
|
||||
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.1.3"
|
||||
|
|
@ -6359,10 +6351,10 @@ files = [
|
|||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scikit-learn"
|
||||
|
|
@ -6515,9 +6507,9 @@ tornado = ">=6.4.2,<7"
|
|||
urllib3 = ">=1.26,<3"
|
||||
|
||||
[package.extras]
|
||||
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"]
|
||||
cohere = ["cohere (>=5.9.4,<6.0)"]
|
||||
cohere = ["cohere (>=5.9.4,<6.00)"]
|
||||
dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""]
|
||||
fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""]
|
||||
|
|
@ -7245,7 +7237,6 @@ files = [
|
|||
{file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
|
||||
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
|
|
@ -8018,4 +8009,4 @@ utils = ["numpydoc"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.9,<4.0"
|
||||
content-hash = "2cf958f1a04fd5f1ab0e5cfc33bdbf441b518ed6c82d0f2546bf64cd3d2f89be"
|
||||
content-hash = "c2b65cf2afb0783061ee8156f8d814b0d950bc3803821a97f9775a287a75fa6e"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
|
|||
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
|
||||
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
|
||||
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
|
||||
litellm-proxy-extras = {version = "^0.4.58", optional = true}
|
||||
litellm-proxy-extras = {version = "^0.4.59", optional = true}
|
||||
rich = {version = "^13.7.1", optional = true}
|
||||
litellm-enterprise = {version = "^0.1.33", optional = true}
|
||||
diskcache = {version = "^5.6.1", optional = true}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
|
|||
sentry_sdk==2.21.0 # for sentry error handling
|
||||
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
|
||||
tzdata==2025.1 # IANA time zone database
|
||||
litellm-proxy-extras==0.4.58 # for proxy extras - e.g. prisma migrations
|
||||
litellm-proxy-extras==0.4.59 # for proxy extras - e.g. prisma migrations
|
||||
llm-sandbox==0.3.31 # for skill execution in sandbox
|
||||
### LITELLM PACKAGE DEPENDENCIES
|
||||
python-dotenv==1.0.1 # for env
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable {
|
|||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
search_tools String[] @default(["*"]) // ["*"] = all access (default), [] = no access, ["tool-a"] = only those tools
|
||||
agents String[] @default([])
|
||||
agent_access_groups String[] @default([])
|
||||
models String[] @default([])
|
||||
|
|
@ -297,6 +298,7 @@ model LiteLLM_MCPServerTable {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
team_id String?
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
|
|
|
|||
|
|
@ -157,3 +157,127 @@ class TestUpdateMetadataFieldsPremiumCheck:
|
|||
}
|
||||
_update_metadata_fields(updated_kv)
|
||||
mock_check.assert_called()
|
||||
|
||||
|
||||
class TestCheckMemberPermission:
|
||||
"""Tests for check_member_permission and _find_member_in_team."""
|
||||
|
||||
def _make_user_api_key_dict(self, user_id="user-1", user_role=None):
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
return UserAPIKeyAuth(
|
||||
user_id=user_id,
|
||||
user_role=user_role or LitellmUserRoles.INTERNAL_USER,
|
||||
api_key="sk-test",
|
||||
)
|
||||
|
||||
def _make_team(self, members):
|
||||
from litellm.proxy._types import LiteLLM_TeamTable
|
||||
|
||||
return LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
members_with_roles=members,
|
||||
)
|
||||
|
||||
def test_proxy_admin_always_allowed(self):
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
team = self._make_team([])
|
||||
assert check_member_permission(user, team, "mcp:create") is True
|
||||
|
||||
def test_team_admin_always_allowed(self):
|
||||
from litellm.proxy._types import Member
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(user_id="admin-1")
|
||||
team = self._make_team(
|
||||
[Member(user_id="admin-1", role="admin")]
|
||||
)
|
||||
assert check_member_permission(user, team, "mcp:create") is True
|
||||
|
||||
def test_member_with_permission_allowed(self):
|
||||
from litellm.proxy._types import Member
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(user_id="member-1")
|
||||
team = self._make_team(
|
||||
[Member(user_id="member-1", role="user", extra_permissions=["mcp:create"])]
|
||||
)
|
||||
assert check_member_permission(user, team, "mcp:create") is True
|
||||
|
||||
def test_member_without_permission_denied(self):
|
||||
from litellm.proxy._types import Member
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(user_id="member-1")
|
||||
team = self._make_team(
|
||||
[Member(user_id="member-1", role="user", extra_permissions=["mcp:read"])]
|
||||
)
|
||||
assert check_member_permission(user, team, "mcp:create") is False
|
||||
|
||||
def test_member_with_no_permissions_denied(self):
|
||||
from litellm.proxy._types import Member
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(user_id="member-1")
|
||||
team = self._make_team(
|
||||
[Member(user_id="member-1", role="user")]
|
||||
)
|
||||
assert check_member_permission(user, team, "mcp:create") is False
|
||||
|
||||
def test_user_not_in_team_denied(self):
|
||||
from litellm.proxy._types import Member
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
check_member_permission,
|
||||
)
|
||||
|
||||
user = self._make_user_api_key_dict(user_id="outsider")
|
||||
team = self._make_team(
|
||||
[Member(user_id="member-1", role="user", extra_permissions=["mcp:create"])]
|
||||
)
|
||||
assert check_member_permission(user, team, "mcp:create") is False
|
||||
|
||||
|
||||
class TestMemberExtraPermissionsSerialization:
|
||||
"""Verify Member with extra_permissions round-trips through JSON."""
|
||||
|
||||
def test_member_with_permissions_roundtrip(self):
|
||||
import json
|
||||
|
||||
from litellm.proxy._types import Member
|
||||
|
||||
original = Member(
|
||||
user_id="user-1",
|
||||
role="user",
|
||||
extra_permissions=["mcp:create", "mcp:delete"],
|
||||
)
|
||||
serialized = json.dumps(original.model_dump())
|
||||
deserialized = Member(**json.loads(serialized))
|
||||
assert deserialized.extra_permissions == ["mcp:create", "mcp:delete"]
|
||||
assert deserialized.user_id == "user-1"
|
||||
assert deserialized.role == "user"
|
||||
|
||||
def test_member_without_permissions_roundtrip(self):
|
||||
import json
|
||||
|
||||
from litellm.proxy._types import Member
|
||||
|
||||
original = Member(user_id="user-1", role="admin")
|
||||
serialized = json.dumps(original.model_dump())
|
||||
deserialized = Member(**json.loads(serialized))
|
||||
assert deserialized.extra_permissions is None
|
||||
assert deserialized.role == "admin"
|
||||
|
|
|
|||
|
|
@ -1474,6 +1474,7 @@ async def test_add_update_server_with_alias():
|
|||
mock_mcp_server.byok_api_key_help_url = None
|
||||
mock_mcp_server.created_at = None
|
||||
mock_mcp_server.updated_at = None
|
||||
mock_mcp_server.team_id = None
|
||||
|
||||
# Add server to manager
|
||||
await test_manager.add_server(mock_mcp_server)
|
||||
|
|
@ -1530,6 +1531,7 @@ async def test_add_update_server_without_alias():
|
|||
mock_mcp_server.byok_api_key_help_url = None
|
||||
mock_mcp_server.created_at = None
|
||||
mock_mcp_server.updated_at = None
|
||||
mock_mcp_server.team_id = None
|
||||
|
||||
# Add server to manager
|
||||
await test_manager.add_server(mock_mcp_server)
|
||||
|
|
@ -1587,6 +1589,7 @@ async def test_add_update_server_fallback_to_server_id():
|
|||
mock_mcp_server.byok_api_key_help_url = None
|
||||
mock_mcp_server.created_at = None
|
||||
mock_mcp_server.updated_at = None
|
||||
mock_mcp_server.team_id = None
|
||||
|
||||
# Add server to manager
|
||||
await test_manager.add_server(mock_mcp_server)
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ async def test_create_duplicate_mcp_server():
|
|||
@pytest.mark.asyncio
|
||||
async def test_create_mcp_server_auth_failure():
|
||||
"""
|
||||
Test that non-admin users cannot create MCP servers.
|
||||
Test that non-admin users without a team_id cannot create MCP servers.
|
||||
"""
|
||||
# Mock the database functions directly
|
||||
with mock.patch(
|
||||
|
|
@ -291,15 +291,15 @@ async def test_create_mcp_server_auth_failure():
|
|||
user_role=LitellmUserRoles.INTERNAL_USER, # Not an admin
|
||||
)
|
||||
|
||||
# Expect HTTPException to be raised
|
||||
# Expect HTTPException — non-admin users must provide team_id
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await add_mcp_server(
|
||||
payload=mcp_server_request, user_api_key_dict=user_auth
|
||||
)
|
||||
|
||||
# Verify the exception details
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "permission" in str(exc_info.value.detail)
|
||||
# Non-admin without team_id gets a 400 requiring team_id
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "team_id is required" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -624,6 +624,7 @@ class TestSigV4BuildFromTable:
|
|||
table_record.tool_name_to_description = None
|
||||
table_record.byok_api_key_help_url = None
|
||||
table_record.oauth2_flow = None
|
||||
table_record.team_id = None
|
||||
|
||||
manager = MCPServerManager()
|
||||
|
||||
|
|
@ -681,6 +682,7 @@ class TestSigV4BuildFromTable:
|
|||
table_record.tool_name_to_description = None
|
||||
table_record.byok_api_key_help_url = None
|
||||
table_record.oauth2_flow = None
|
||||
table_record.team_id = None
|
||||
|
||||
manager = MCPServerManager()
|
||||
|
||||
|
|
|
|||
509
tests/test_litellm/proxy/auth/test_search_tool_access.py
Normal file
509
tests/test_litellm/proxy/auth/test_search_tool_access.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"""
|
||||
Tests for search tool access control.
|
||||
|
||||
Covers:
|
||||
- _can_object_call_search_tools() with least-privilege semantics
|
||||
- search_tool_access_check() for key and team level permissions
|
||||
- ProxyErrorTypes for search tool access denied
|
||||
- Ensures vector store access check semantics are unchanged
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
_can_object_call_search_tools,
|
||||
_can_object_call_vector_stores,
|
||||
_normalize_search_tools_wildcard,
|
||||
get_allowed_search_tool_names,
|
||||
search_tool_access_check,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_object_permission(
|
||||
search_tools: Optional[List[str]] = None,
|
||||
vector_stores: Optional[List[str]] = None,
|
||||
) -> LiteLLM_ObjectPermissionTable:
|
||||
"""Create a minimal object permission for testing."""
|
||||
return LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="test-perm-id",
|
||||
search_tools=search_tools,
|
||||
vector_stores=vector_stores if vector_stores is not None else [],
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _can_object_call_search_tools — least-privilege semantics
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCanObjectCallSearchTools:
|
||||
"""should enforce least-privilege semantics for search tools."""
|
||||
|
||||
def test_should_allow_when_permissions_are_none(self):
|
||||
"""None object_permissions → allow (no permission record)."""
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="any-tool",
|
||||
object_permissions=None,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_allow_when_search_tools_field_is_none(self):
|
||||
"""search_tools=None → allow (field not configured)."""
|
||||
perm = _make_object_permission(search_tools=None)
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="any-tool",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_deny_when_search_tools_is_empty_list(self):
|
||||
"""search_tools=[] → DENY (principle of least privilege)."""
|
||||
perm = _make_object_permission(search_tools=[])
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="any-tool",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
|
||||
|
||||
def test_should_allow_tool_in_allowed_list(self):
|
||||
"""Requesting a tool that is in the allowed list → allow."""
|
||||
perm = _make_object_permission(search_tools=["tool-a", "tool-b"])
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="tool-a",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_deny_tool_not_in_allowed_list(self):
|
||||
"""Requesting a tool NOT in the allowed list → deny."""
|
||||
perm = _make_object_permission(search_tools=["tool-a"])
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="tool-b",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
|
||||
|
||||
def test_should_use_team_error_type_for_team_object(self):
|
||||
"""Team denial uses team-specific error type."""
|
||||
perm = _make_object_permission(search_tools=[])
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_search_tools(
|
||||
object_type="team",
|
||||
search_tool_name="any-tool",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
|
||||
|
||||
def test_should_use_org_error_type_for_org_object(self):
|
||||
"""Org denial uses org-specific error type."""
|
||||
perm = _make_object_permission(search_tools=["other"])
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_search_tools(
|
||||
object_type="org",
|
||||
search_tool_name="not-other",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.org_search_tool_access_denied
|
||||
|
||||
def test_should_allow_any_tool_when_wildcard_present(self):
|
||||
"""search_tools=["*"] (migration default) → allow any tool."""
|
||||
perm = _make_object_permission(search_tools=["*"])
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="any-tool",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_allow_any_tool_when_wildcard_mixed_with_names(self):
|
||||
"""search_tools=["*", "tool-a"] → wildcard dominates, allow any."""
|
||||
perm = _make_object_permission(search_tools=["*", "tool-a"])
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="tool-b",
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_allow_all_tools_in_allowed_list(self):
|
||||
"""Multiple tools in allowed list all pass."""
|
||||
perm = _make_object_permission(search_tools=["a", "b", "c"])
|
||||
for name in ["a", "b", "c"]:
|
||||
result = _can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name=name,
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
with pytest.raises(ProxyException):
|
||||
_can_object_call_search_tools(
|
||||
object_type="key",
|
||||
search_tool_name="d",
|
||||
object_permissions=perm,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# search_tool_access_check — async DB lookups
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
_PROXY_SERVER = "litellm.proxy.proxy_server"
|
||||
_GET_OBJ_PERM = "litellm.proxy.auth.auth_checks.get_object_permission"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_allow_when_no_prisma_client():
|
||||
"""No prisma client → allow."""
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", None):
|
||||
result = await search_tool_access_check(
|
||||
search_tool_name="any-tool",
|
||||
valid_token=None,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_allow_when_no_token():
|
||||
"""No valid_token → allow."""
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()):
|
||||
result = await search_tool_access_check(
|
||||
search_tool_name="any-tool",
|
||||
valid_token=None,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_allow_when_no_permission_ids():
|
||||
"""Token with no object_permission_id or team_object_permission_id → allow."""
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id=None,
|
||||
team_object_permission_id=None,
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()):
|
||||
result = await search_tool_access_check(
|
||||
search_tool_name="any-tool",
|
||||
valid_token=token,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_allow_key_with_matching_permission():
|
||||
"""Key with object_permission that includes the tool → allow."""
|
||||
mock_perm = MagicMock()
|
||||
mock_perm.search_tools = ["my-tool"]
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id=None,
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)):
|
||||
result = await search_tool_access_check(
|
||||
search_tool_name="my-tool",
|
||||
valid_token=token,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_deny_key_with_empty_search_tools():
|
||||
"""Key with empty search_tools → deny."""
|
||||
mock_perm = MagicMock()
|
||||
mock_perm.search_tools = []
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id=None,
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await search_tool_access_check(
|
||||
search_tool_name="any-tool",
|
||||
valid_token=token,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_deny_team_with_empty_search_tools():
|
||||
"""Team with empty search_tools → deny."""
|
||||
mock_team_perm = MagicMock()
|
||||
mock_team_perm.search_tools = []
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id=None,
|
||||
team_object_permission_id="team-perm-id",
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_team_perm)):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await search_tool_access_check(
|
||||
search_tool_name="any-tool",
|
||||
valid_token=token,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_deny_when_key_allows_but_team_denies():
|
||||
"""Key allows, team denies → deny (team check second)."""
|
||||
key_perm = MagicMock()
|
||||
key_perm.search_tools = ["the-tool"]
|
||||
|
||||
team_perm = MagicMock()
|
||||
team_perm.search_tools = []
|
||||
|
||||
async def mock_get_perm(object_permission_id, **kwargs):
|
||||
if object_permission_id == "key-perm-id":
|
||||
return key_perm
|
||||
elif object_permission_id == "team-perm-id":
|
||||
return team_perm
|
||||
return None
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id="team-perm-id",
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await search_tool_access_check(
|
||||
search_tool_name="the-tool",
|
||||
valid_token=token,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_allow_when_both_key_and_team_allow():
|
||||
"""Both key and team allow → allow."""
|
||||
key_perm = MagicMock()
|
||||
key_perm.search_tools = ["tool-x"]
|
||||
|
||||
team_perm = MagicMock()
|
||||
team_perm.search_tools = ["tool-x", "tool-y"]
|
||||
|
||||
async def mock_get_perm(object_permission_id, **kwargs):
|
||||
if object_permission_id == "key-perm-id":
|
||||
return key_perm
|
||||
elif object_permission_id == "team-perm-id":
|
||||
return team_perm
|
||||
return None
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id="team-perm-id",
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
|
||||
result = await search_tool_access_check(
|
||||
search_tool_name="tool-x",
|
||||
valid_token=token,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ProxyErrorTypes classmethod
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSearchToolErrorTypes:
|
||||
def test_should_return_key_error_type(self):
|
||||
assert (
|
||||
ProxyErrorTypes.get_search_tool_access_error_type_for_object("key")
|
||||
== ProxyErrorTypes.key_search_tool_access_denied
|
||||
)
|
||||
|
||||
def test_should_return_team_error_type(self):
|
||||
assert (
|
||||
ProxyErrorTypes.get_search_tool_access_error_type_for_object("team")
|
||||
== ProxyErrorTypes.team_search_tool_access_denied
|
||||
)
|
||||
|
||||
def test_should_return_org_error_type(self):
|
||||
assert (
|
||||
ProxyErrorTypes.get_search_tool_access_error_type_for_object("org")
|
||||
== ProxyErrorTypes.org_search_tool_access_denied
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Regression: vector store semantics unchanged (empty = allow all)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVectorStoreAccessNotBroken:
|
||||
"""Preserves existing vector store semantics: empty list = allow ALL."""
|
||||
|
||||
def test_should_allow_all_when_vector_stores_is_empty(self):
|
||||
"""Vector stores: empty list = access to ALL (existing behavior)."""
|
||||
perm = MagicMock()
|
||||
perm.vector_stores = []
|
||||
result = _can_object_call_vector_stores(
|
||||
object_type="key",
|
||||
vector_store_ids_to_run=["store-1"],
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_allow_when_vector_stores_is_none(self):
|
||||
perm = MagicMock()
|
||||
perm.vector_stores = None
|
||||
result = _can_object_call_vector_stores(
|
||||
object_type="key",
|
||||
vector_store_ids_to_run=["store-1"],
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_should_deny_unlisted_vector_store(self):
|
||||
perm = MagicMock()
|
||||
perm.vector_stores = ["store-1"]
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_vector_stores(
|
||||
object_type="key",
|
||||
vector_store_ids_to_run=["store-99"],
|
||||
object_permissions=perm,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _normalize_search_tools_wildcard
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestNormalizeSearchToolsWildcard:
|
||||
def test_none_stays_none(self):
|
||||
assert _normalize_search_tools_wildcard(None) is None
|
||||
|
||||
def test_empty_list_stays_empty(self):
|
||||
assert _normalize_search_tools_wildcard([]) == []
|
||||
|
||||
def test_explicit_names_unchanged(self):
|
||||
assert _normalize_search_tools_wildcard(["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_wildcard_only_returns_none(self):
|
||||
assert _normalize_search_tools_wildcard(["*"]) is None
|
||||
|
||||
def test_wildcard_mixed_returns_none(self):
|
||||
assert _normalize_search_tools_wildcard(["*", "tool-a"]) is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# get_allowed_search_tool_names — wildcard handling
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_allowed_should_return_none_when_key_has_wildcard():
|
||||
"""Key with ["*"] → None (no restriction)."""
|
||||
key_perm = MagicMock()
|
||||
key_perm.search_tools = ["*"]
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id=None,
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(return_value=key_perm)):
|
||||
result = await get_allowed_search_tool_names(token)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_allowed_should_intersect_wildcard_key_with_team_list():
|
||||
"""Key ["*"] + team ["tool-a"] → ["tool-a"]."""
|
||||
key_perm = MagicMock()
|
||||
key_perm.search_tools = ["*"]
|
||||
|
||||
team_perm = MagicMock()
|
||||
team_perm.search_tools = ["tool-a"]
|
||||
|
||||
async def mock_get_perm(object_permission_id, **kwargs):
|
||||
if object_permission_id == "key-perm-id":
|
||||
return key_perm
|
||||
elif object_permission_id == "team-perm-id":
|
||||
return team_perm
|
||||
return None
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id="team-perm-id",
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
|
||||
result = await get_allowed_search_tool_names(token)
|
||||
assert result == ["tool-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_allowed_should_return_none_when_both_wildcard():
|
||||
"""Key ["*"] + team ["*"] → None (no restriction)."""
|
||||
key_perm = MagicMock()
|
||||
key_perm.search_tools = ["*"]
|
||||
|
||||
team_perm = MagicMock()
|
||||
team_perm.search_tools = ["*"]
|
||||
|
||||
async def mock_get_perm(object_permission_id, **kwargs):
|
||||
if object_permission_id == "key-perm-id":
|
||||
return key_perm
|
||||
elif object_permission_id == "team-perm-id":
|
||||
return team_perm
|
||||
return None
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
object_permission_id="key-perm-id",
|
||||
team_object_permission_id="team-perm-id",
|
||||
)
|
||||
with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \
|
||||
patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \
|
||||
patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)):
|
||||
result = await get_allowed_search_tool_names(token)
|
||||
assert result is None
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""Tests for MCP server team ownership via MCPServerTable.team_id."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_prepare_mcp_server_data_includes_team_id():
|
||||
"""team_id should be included in data dict when set."""
|
||||
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
|
||||
from litellm.proxy._types import NewMCPServerRequest
|
||||
|
||||
team_id = str(uuid.uuid4())
|
||||
request = NewMCPServerRequest(
|
||||
server_name="test_server",
|
||||
transport="http",
|
||||
url="https://example.com/mcp",
|
||||
team_id=team_id,
|
||||
)
|
||||
data_dict = _prepare_mcp_server_data(request)
|
||||
assert data_dict["team_id"] == team_id
|
||||
|
||||
|
||||
def test_prepare_mcp_server_data_excludes_none_team_id():
|
||||
"""team_id=None should not be in the data dict (exclude_none=True)."""
|
||||
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
|
||||
from litellm.proxy._types import NewMCPServerRequest
|
||||
|
||||
request = NewMCPServerRequest(
|
||||
server_name="test_server",
|
||||
transport="http",
|
||||
url="https://example.com/mcp",
|
||||
)
|
||||
data_dict = _prepare_mcp_server_data(request)
|
||||
assert "team_id" not in data_dict
|
||||
|
||||
|
||||
def test_prepare_mcp_server_data_update_includes_team_id():
|
||||
"""UpdateMCPServerRequest with team_id should include it in data dict."""
|
||||
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
|
||||
from litellm.proxy._types import UpdateMCPServerRequest
|
||||
|
||||
team_id = str(uuid.uuid4())
|
||||
request = UpdateMCPServerRequest(
|
||||
server_id="test-server-id",
|
||||
transport="http",
|
||||
url="https://example.com/mcp",
|
||||
team_id=team_id,
|
||||
)
|
||||
data_dict = _prepare_mcp_server_data(request)
|
||||
assert data_dict["team_id"] == team_id
|
||||
|
||||
|
||||
def test_prepare_mcp_server_data_update_excludes_none_team_id():
|
||||
"""UpdateMCPServerRequest without team_id should not have it in data dict."""
|
||||
from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data
|
||||
from litellm.proxy._types import UpdateMCPServerRequest
|
||||
|
||||
request = UpdateMCPServerRequest(
|
||||
server_id="test-server-id",
|
||||
transport="http",
|
||||
url="https://example.com/mcp",
|
||||
)
|
||||
data_dict = _prepare_mcp_server_data(request)
|
||||
assert "team_id" not in data_dict
|
||||
|
||||
|
||||
def test_update_request_model_fields_set_detects_explicit_null():
|
||||
"""When team_id is explicitly set to None in JSON, model_fields_set should contain it."""
|
||||
from litellm.proxy._types import UpdateMCPServerRequest
|
||||
|
||||
# Simulate JSON: {"server_id": "x", "transport": "http", "url": "...", "team_id": null}
|
||||
request = UpdateMCPServerRequest.model_validate(
|
||||
{"server_id": "x", "transport": "http", "url": "https://example.com/mcp", "team_id": None}
|
||||
)
|
||||
assert "team_id" in request.model_fields_set
|
||||
|
||||
# Simulate JSON: {"server_id": "x", "transport": "http", "url": "..."} — team_id absent
|
||||
request2 = UpdateMCPServerRequest.model_validate(
|
||||
{"server_id": "x", "transport": "http", "url": "https://example.com/mcp"}
|
||||
)
|
||||
assert "team_id" not in request2.model_fields_set
|
||||
|
|
@ -36,10 +36,18 @@ export const useMCPServerHealth = () => {
|
|||
{ queryKey: mcpServerHealthKeys.lists() },
|
||||
(oldData) => {
|
||||
if (!oldData) return result;
|
||||
return oldData.map((h) => {
|
||||
const updated = result.find((r) => r.server_id === h.server_id);
|
||||
return updated ?? h;
|
||||
const existingIds = new Set(oldData.map((h) => h.server_id));
|
||||
const updated = oldData.map((h) => {
|
||||
const fresh = result.find((r) => r.server_id === h.server_id);
|
||||
return fresh ?? h;
|
||||
});
|
||||
// Append servers not already in the cached list
|
||||
for (const r of result) {
|
||||
if (!existingIds.has(r.server_id)) {
|
||||
updated.push(r);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
usePolicyVersions,
|
||||
useCreatePolicyVersion,
|
||||
useUpdatePolicyVersionStatus,
|
||||
} from "./usePolicyVersions";
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockListPolicyVersions = vi.fn();
|
||||
const mockCreatePolicyVersion = vi.fn();
|
||||
const mockUpdatePolicyVersionStatus = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
listPolicyVersions: (...args: unknown[]) => mockListPolicyVersions(...args),
|
||||
createPolicyVersion: (...args: unknown[]) => mockCreatePolicyVersion(...args),
|
||||
updatePolicyVersionStatus: (...args: unknown[]) =>
|
||||
mockUpdatePolicyVersionStatus(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the mocked module to assert on it
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("../useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("usePolicyVersions", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
userId: "test-user-id",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
// ── Query tests ─────────────────────────────────────────────────────────
|
||||
|
||||
it("fetches versions when policyName is provided", async () => {
|
||||
const mockResponse = {
|
||||
policy_name: "my-policy",
|
||||
versions: [
|
||||
{ policy_id: "v1", policy_name: "my-policy", version_number: 1, version_status: "production" },
|
||||
{ policy_id: "v2", policy_name: "my-policy", version_number: 2, version_status: "draft" },
|
||||
],
|
||||
total_count: 2,
|
||||
};
|
||||
mockListPolicyVersions.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => usePolicyVersions({ policyName: "my-policy" }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(mockListPolicyVersions).toHaveBeenCalledWith("test-access-token", "my-policy");
|
||||
expect(result.current.data?.versions).toHaveLength(2);
|
||||
expect(result.current.data?.versions[0].policy_id).toBe("v1");
|
||||
});
|
||||
|
||||
it("does not fetch when policyName is null", () => {
|
||||
const { result } = renderHook(
|
||||
() => usePolicyVersions({ policyName: null }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
// isLoading (not isPending) must be false when query is disabled
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(mockListPolicyVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch when enabled is false", () => {
|
||||
const { result } = renderHook(
|
||||
() => usePolicyVersions({ policyName: "my-policy", enabled: false }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
// isLoading (not isPending) must be false when query is disabled
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(mockListPolicyVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults versions to empty array when response has undefined versions", async () => {
|
||||
mockListPolicyVersions.mockResolvedValue({
|
||||
policy_name: "my-policy",
|
||||
versions: undefined,
|
||||
total_count: 0,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => usePolicyVersions({ policyName: "my-policy" }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreatePolicyVersion", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
userId: "test-user-id",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("calls createPolicyVersion and shows success notification", async () => {
|
||||
const newPolicy = { policy_id: "v3", policy_name: "my-policy", version_number: 3 };
|
||||
mockCreatePolicyVersion.mockResolvedValue(newPolicy);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreatePolicyVersion("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
const returned = await result.current.mutateAsync();
|
||||
|
||||
expect(mockCreatePolicyVersion).toHaveBeenCalledWith("test-access-token", "my-policy");
|
||||
expect(returned).toEqual(newPolicy);
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith("New draft version created");
|
||||
});
|
||||
|
||||
it("invalidates the versions cache on success", async () => {
|
||||
mockCreatePolicyVersion.mockResolvedValue({ policy_id: "v3" });
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreatePolicyVersion("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["policyVersions", "detail", "my-policy"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error notification on failure", async () => {
|
||||
mockCreatePolicyVersion.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreatePolicyVersion("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await expect(result.current.mutateAsync()).rejects.toThrow("Server error");
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
"Failed to create version: Server error"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when policyName is null", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useCreatePolicyVersion(null),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await expect(result.current.mutateAsync()).rejects.toThrow(
|
||||
"Missing access token or policy name"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdatePolicyVersionStatus", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
userId: "test-user-id",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("publishes a version and shows success notification", async () => {
|
||||
const updatedPolicy = { policy_id: "v2", version_status: "published" };
|
||||
mockUpdatePolicyVersionStatus.mockResolvedValue(updatedPolicy);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
const returned = await result.current.mutateAsync({
|
||||
policyId: "v2",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(mockUpdatePolicyVersionStatus).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
"v2",
|
||||
"published"
|
||||
);
|
||||
expect(returned).toEqual(updatedPolicy);
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Version published")
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates the versions cache on success", async () => {
|
||||
mockUpdatePolicyVersionStatus.mockResolvedValue({ policy_id: "v2" });
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await result.current.mutateAsync({ policyId: "v2", status: "published" });
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["policyVersions", "detail", "my-policy"],
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes to production and shows success notification", async () => {
|
||||
const updatedPolicy = { policy_id: "v2", version_status: "production" };
|
||||
mockUpdatePolicyVersionStatus.mockResolvedValue(updatedPolicy);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await result.current.mutateAsync({
|
||||
policyId: "v2",
|
||||
status: "production",
|
||||
});
|
||||
|
||||
expect(mockUpdatePolicyVersionStatus).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
"v2",
|
||||
"production"
|
||||
);
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith(
|
||||
"Version promoted to production"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error notification on publish failure", async () => {
|
||||
mockUpdatePolicyVersionStatus.mockRejectedValue(new Error("Forbidden"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({ policyId: "v2", status: "published" })
|
||||
).rejects.toThrow("Forbidden");
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
"Failed to publish: Forbidden"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error notification on promote failure", async () => {
|
||||
mockUpdatePolicyVersionStatus.mockRejectedValue(new Error("Not found"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus("my-policy"),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({ policyId: "v2", status: "production" })
|
||||
).rejects.toThrow("Not found");
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
"Failed to promote to production: Not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when policyName is null", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useUpdatePolicyVersionStatus(null),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({ policyId: "v2", status: "published" })
|
||||
).rejects.toThrow("Missing access token or policy name");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import {
|
||||
listPolicyVersions,
|
||||
createPolicyVersion,
|
||||
updatePolicyVersionStatus,
|
||||
} from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Policy } from "@/components/policies/types";
|
||||
|
||||
// ── Query keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const policyVersionKeys = createQueryKeys("policyVersions");
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PolicyVersionsResponse {
|
||||
policy_name: string;
|
||||
versions: Policy[] | undefined;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/** Output type after `select` normalizes the response — versions is always defined. */
|
||||
export interface PolicyVersionsData {
|
||||
policy_name: string;
|
||||
versions: Policy[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// ── Fetch function ──────────────────────────────────────────────────────────
|
||||
|
||||
const fetchPolicyVersions = async (
|
||||
accessToken: string,
|
||||
policyName: string
|
||||
): Promise<PolicyVersionsResponse> => {
|
||||
return await listPolicyVersions(accessToken, policyName);
|
||||
};
|
||||
|
||||
// ── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UsePolicyVersionsOptions {
|
||||
policyName: string | null | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Stable key used when the query is disabled to avoid undefined in cache keys. */
|
||||
const DISABLED_POLICY_KEY = "__disabled__";
|
||||
|
||||
export const usePolicyVersions = ({
|
||||
policyName,
|
||||
enabled = true,
|
||||
}: UsePolicyVersionsOptions) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const isEnabled = Boolean(accessToken && policyName && enabled);
|
||||
|
||||
return useQuery<PolicyVersionsResponse, Error, PolicyVersionsData>({
|
||||
queryKey: policyVersionKeys.detail(policyName ?? DISABLED_POLICY_KEY),
|
||||
queryFn: async () => await fetchPolicyVersions(accessToken!, policyName!),
|
||||
enabled: isEnabled,
|
||||
select: (data) => ({
|
||||
...data,
|
||||
versions: data.versions ?? [],
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const useCreatePolicyVersion = (policyName: string | null | undefined) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Policy, Error>({
|
||||
mutationFn: async () => {
|
||||
if (!accessToken || !policyName) {
|
||||
throw new Error("Missing access token or policy name");
|
||||
}
|
||||
return await createPolicyVersion(accessToken, policyName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("New draft version created");
|
||||
if (policyName) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: policyVersionKeys.detail(policyName),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to create version: " + error.message
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePolicyVersionStatus = (
|
||||
policyName: string | null | undefined
|
||||
) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
Policy,
|
||||
Error,
|
||||
{ policyId: string; status: "published" | "production" }
|
||||
>({
|
||||
mutationFn: async ({ policyId, status }) => {
|
||||
if (!accessToken || !policyName) {
|
||||
throw new Error("Missing access token or policy name");
|
||||
}
|
||||
return await updatePolicyVersionStatus(accessToken, policyId, status);
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const label =
|
||||
variables.status === "published"
|
||||
? "Version published. You can test it in the Playground by selecting this version in the Policies dropdown."
|
||||
: "Version promoted to production";
|
||||
NotificationsManager.success(label);
|
||||
if (policyName) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: policyVersionKeys.detail(policyName),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const action =
|
||||
variables.status === "published" ? "publish" : "promote to production";
|
||||
NotificationsManager.fromBackend(
|
||||
`Failed to ${action}: ${error.message}`
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchTeams } from "@/app/(dashboard)/networking";
|
||||
|
|
@ -124,6 +124,43 @@ export const useTeam = (teamId?: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
const infiniteTeamKeys = createQueryKeys("infiniteTeams");
|
||||
|
||||
export const useInfiniteTeams = (
|
||||
pageSize: number = 50,
|
||||
search?: string,
|
||||
organizationId?: string | null,
|
||||
) => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const isAdmin = userRole === "Admin" || userRole === "Admin Viewer";
|
||||
|
||||
return useInfiniteQuery<TeamsResponse>({
|
||||
queryKey: infiniteTeamKeys.list({
|
||||
filters: {
|
||||
pageSize,
|
||||
...(search && { search }),
|
||||
...(organizationId && { organizationId }),
|
||||
...(userId && { userId }),
|
||||
},
|
||||
}),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return await teamListCall(accessToken!, pageParam as number, pageSize, {
|
||||
team_alias: search || undefined,
|
||||
organizationID: organizationId,
|
||||
userID: !isAdmin ? userId : undefined,
|
||||
});
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.page < lastPage.total_pages) {
|
||||
return lastPage.page + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
};
|
||||
|
||||
const deletedTeamListCall = async (
|
||||
accessToken: string,
|
||||
page: number,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import Navbar from "@/components/navbar";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
|
||||
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
||||
|
|
@ -23,6 +23,17 @@ function withBase(path: string): string {
|
|||
}
|
||||
/** -------------------------------- */
|
||||
|
||||
/**
|
||||
* Pages that have been migrated to path-based routing under (dashboard)/.
|
||||
* When the leftnav triggers one of these, navigate to the path route instead
|
||||
* of the legacy query-param root page.
|
||||
*
|
||||
* Key = legacy page id used in leftnav, Value = route segment under (dashboard)/
|
||||
*/
|
||||
const MIGRATED_PAGES: Record<string, string> = {
|
||||
"api-reference": "api-reference",
|
||||
};
|
||||
|
||||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -32,10 +43,17 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||
return searchParams.get("page") || "api-keys";
|
||||
});
|
||||
|
||||
const updatePage = (newPage: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("page", newPage);
|
||||
router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE
|
||||
const handleSetPage = (newPage: string) => {
|
||||
// If the page has been migrated to path routing, navigate there
|
||||
const migratedRoute = MIGRATED_PAGES[newPage];
|
||||
if (migratedRoute) {
|
||||
router.push(withBase(migratedRoute));
|
||||
setPage(newPage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, navigate back to the legacy root page with query params
|
||||
router.push(withBase(`?page=${newPage}`));
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +83,11 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||
<DebugWarningBanner />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<Sidebar2 defaultSelectedKey={page} accessToken={accessToken} userRole={userRole} />
|
||||
<SidebarProvider
|
||||
setPage={handleSetPage}
|
||||
defaultSelectedKey={page}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -210,9 +210,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Team" name="team_id">
|
||||
<Select placeholder="Select Team" style={{ width: "100%" }}>
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
</Select>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
|
|
@ -294,7 +292,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
name="team_id"
|
||||
help="If selected, user will be added as a 'user' role to the team."
|
||||
>
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
TextInput,
|
||||
} from "@tremor/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
|
|
@ -59,6 +60,7 @@ import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
|||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import SearchToolSelector from "./SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface TeamProps {
|
||||
|
|
@ -563,6 +565,14 @@ const Teams: React.FC<TeamProps> = ({
|
|||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
// Always send search_tools to ensure the permission record is created.
|
||||
// Empty array = no access (least privilege for new teams).
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
|
||||
delete formValues.allowed_search_tool_ids;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
|
|
@ -579,14 +589,12 @@ const Teams: React.FC<TeamProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const response: any = await teamCreateCall(accessToken, formValues);
|
||||
if (teams !== null) {
|
||||
setTeams([...teams, response]);
|
||||
} else {
|
||||
setTeams([response]);
|
||||
}
|
||||
console.log(`response for team create call: ${response}`);
|
||||
await teamCreateCall(accessToken, formValues);
|
||||
NotificationsManager.success("Team created");
|
||||
await fetchTeamsV2({
|
||||
page: currentPage,
|
||||
size: pageSize,
|
||||
});
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
|
|
@ -1516,6 +1524,40 @@ const Teams: React.FC<TeamProps> = ({
|
|||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New teams have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this team can access. New teams default to no access — explicitly grant access to specific search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_search_tool_ids"
|
||||
className="mt-4"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
|
||||
value={form.getFieldValue("allowed_search_tool_ids")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,14 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
|
||||
import Image from "next/image";
|
||||
import { Button, Form, Input, Modal, Select, Tooltip, Typography } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ProviderLogo } from "../molecules/models/ProviderLogo";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { createSearchTool, fetchAvailableSearchProviders } from "../networking";
|
||||
import SearchConnectionTest from "./SearchConnectionTest";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
// Search provider logos folder path (matches existing provider logo pattern)
|
||||
const searchProviderLogosFolder = "../ui/assets/logos/";
|
||||
|
||||
// Helper function to get logo path for a search provider
|
||||
const getSearchProviderLogo = (providerName: string): string => {
|
||||
return `${searchProviderLogosFolder}${providerName}.png`;
|
||||
};
|
||||
|
||||
// Component to display search provider logo and name
|
||||
interface SearchProviderLabelProps {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ providerName, displayName }) => (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Image
|
||||
src={getSearchProviderLogo(providerName)}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CreateSearchToolProps {
|
||||
userRole: string;
|
||||
accessToken: string | null;
|
||||
|
|
@ -85,7 +49,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
const handleCreate = async (formValues: Record<string, any>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Prepare the payload
|
||||
const payload = {
|
||||
search_tool_name: formValues.search_tool_name,
|
||||
litellm_params: {
|
||||
|
|
@ -102,8 +65,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
: undefined,
|
||||
};
|
||||
|
||||
console.log(`Creating search tool with payload:`, payload);
|
||||
|
||||
if (accessToken != null) {
|
||||
const response = await createSearchTool(accessToken, payload);
|
||||
|
||||
|
|
@ -128,13 +89,10 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
// Validate required fields for testing
|
||||
await form.validateFields(["search_provider", "api_key"]);
|
||||
|
||||
setIsTestingConnection(true);
|
||||
// Generate a new test ID (using timestamp for uniqueness)
|
||||
setConnectionTestId(`test-${Date.now()}`);
|
||||
// Show the modal with the fresh test
|
||||
setIsTestModalVisible(true);
|
||||
} catch (error) {
|
||||
NotificationsManager.error("Please fill in Search Provider and API Key before testing");
|
||||
|
|
@ -154,144 +112,112 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add New Search Tool</h2>
|
||||
title="Add New Search Tool"
|
||||
open={isModalVisible}
|
||||
width={600}
|
||||
onCancel={handleCancel}
|
||||
footer={
|
||||
<div className="flex justify-between items-center">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
|
||||
Need Help?
|
||||
</Typography.Link>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleTestConnection} loading={isTestingConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={isLoading}>
|
||||
Add Search Tool
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={800}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||
layout="vertical"
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Search Tool Name{" "}
|
||||
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_tool_name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a search tool name" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: "Name can only contain letters, numbers, hyphens, and underscores",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Search Tool Name
|
||||
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_tool_name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a search tool name" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: "Name can only contain letters, numbers, hyphens, and underscores",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="e.g., perplexity-search, my-tavily-tool"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Input placeholder="e.g., perplexity-search, my-tavily-tool" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Search Provider
|
||||
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a search provider"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
loading={isLoadingProviders}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
optionLabelProp="label"
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Search Provider{" "}
|
||||
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a search provider"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
optionLabelProp="label"
|
||||
notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
label={provider.ui_friendly_name}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
label={
|
||||
<SearchProviderLabel
|
||||
providerName={provider.provider_name}
|
||||
displayName={provider.ui_friendly_name}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SearchProviderLabel
|
||||
providerName={provider.provider_name}
|
||||
displayName={provider.ui_friendly_name}
|
||||
/>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<ProviderLogo provider={provider.provider_name} className="w-5 h-5" />
|
||||
<span>{provider.ui_friendly_name}</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
API Key
|
||||
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="api_key"
|
||||
rules={[{ required: false, message: "Please enter an API key" }]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter your API key"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
API Key{" "}
|
||||
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="api_key"
|
||||
>
|
||||
<Input.Password placeholder="Enter your API key" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Description (Optional)</span>}
|
||||
name="description"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="Brief description of this search tool's purpose"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
|
||||
Need Help?
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleTestConnection} loading={isTestingConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button loading={isLoading} type="submit">
|
||||
Add Search Tool
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<Form.Item
|
||||
label="Description (Optional)"
|
||||
name="description"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="Brief description of this search tool's purpose"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* Test Connection Results Modal */}
|
||||
<Modal
|
||||
|
|
@ -314,7 +240,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
]}
|
||||
width={700}
|
||||
>
|
||||
{/* Only render the SearchConnectionTest when modal is visible and we have a test ID */}
|
||||
{isTestModalVisible && accessToken && (
|
||||
<SearchConnectionTest
|
||||
key={connectionTestId}
|
||||
|
|
@ -333,4 +258,3 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
};
|
||||
|
||||
export default CreateSearchTool;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,31 +27,41 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({
|
|||
} | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Run test only once on mount — parent controls remounting via `key` prop
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const runTest = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await testSearchToolConnection(accessToken, litellmParams);
|
||||
if (cancelled) return;
|
||||
setTestResult(result);
|
||||
if (result.status === "success") {
|
||||
NotificationsManager.success("Connection test successful!");
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setTestResult({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error_type: "NetworkError",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (onTestComplete) {
|
||||
onTestComplete();
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
onTestComplete?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runTest();
|
||||
}, [accessToken, litellmParams, onTestComplete]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getCleanErrorMessage = (errorMsg: string) => {
|
||||
if (!errorMsg) return "Unknown error";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const searchToolColumns = (
|
|||
onEdit: (searchToolId: string) => void,
|
||||
onDelete: (searchToolId: string) => void,
|
||||
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
|
||||
isAdmin: boolean = true,
|
||||
): ColumnsType<SearchTool> => [
|
||||
{
|
||||
title: "Search Tool ID",
|
||||
|
|
@ -88,10 +89,10 @@ export const searchToolColumns = (
|
|||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
|
||||
disabled={isFromConfig || !isAdmin}
|
||||
disabledTooltipText={!isAdmin ? "Only admins can edit search tools" : "Config search tool cannot be edited on the dashboard. Please edit it from the config file."}
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
if (toolId && !isFromConfig && isAdmin) {
|
||||
onEdit(toolId);
|
||||
}
|
||||
}}
|
||||
|
|
@ -99,10 +100,10 @@ export const searchToolColumns = (
|
|||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
disabled={isFromConfig || !isAdmin}
|
||||
disabledTooltipText={!isAdmin ? "Only admins can delete search tools" : "Config search tool cannot be deleted on the dashboard. Please delete it from the config file."}
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
if (toolId && !isFromConfig && isAdmin) {
|
||||
onDelete(toolId);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { SearchTool } from "./types";
|
||||
import { fetchSearchTools } from "../networking";
|
||||
|
||||
interface SearchToolSelectorProps {
|
||||
onChange: (selectedSearchTools: string[]) => void;
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When set, only search tools whose IDs appear in this list are shown.
|
||||
* A list containing "*" means all tools are allowed (wildcard / legacy).
|
||||
* Undefined means no filtering (proxy admin without a team context).
|
||||
*/
|
||||
allowedSearchToolIds?: string[];
|
||||
}
|
||||
|
||||
const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select search tools",
|
||||
disabled = false,
|
||||
allowedSearchToolIds,
|
||||
}) => {
|
||||
const [searchTools, setSearchTools] = useState<SearchTool[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSearchTools = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchSearchTools(accessToken);
|
||||
if (response.search_tools) {
|
||||
setSearchTools(response.search_tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching search tools:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSearchTools();
|
||||
}, [accessToken]);
|
||||
|
||||
// Filter tools based on team permissions
|
||||
const filteredTools = useMemo(() => {
|
||||
if (allowedSearchToolIds === undefined) return searchTools;
|
||||
// Wildcard means all tools are allowed
|
||||
if (allowedSearchToolIds.length === 1 && allowedSearchToolIds[0] === "*") return searchTools;
|
||||
// Empty list means no tools are allowed
|
||||
if (allowedSearchToolIds.length === 0) return [];
|
||||
// Filter to only allowed IDs
|
||||
return searchTools.filter(
|
||||
(tool) => allowedSearchToolIds.includes(tool.search_tool_id || tool.search_tool_name),
|
||||
);
|
||||
}, [searchTools, allowedSearchToolIds]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
options={filteredTools.map((tool) => ({
|
||||
label: `${tool.search_tool_name}${tool.search_tool_id ? ` (${tool.search_tool_id})` : ""}`,
|
||||
value: tool.search_tool_id || tool.search_tool_name,
|
||||
title: tool.search_tool_info?.description || tool.search_tool_name,
|
||||
}))}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolSelector;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useState } from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { SearchTool, AvailableSearchProvider } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SearchToolTestPlaygroundProps {
|
||||
searchTools: SearchTool[];
|
||||
availableProviders: AvailableSearchProvider[];
|
||||
isLoading: boolean;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const SearchToolTestPlayground: React.FC<SearchToolTestPlaygroundProps> = ({
|
||||
searchTools,
|
||||
availableProviders,
|
||||
isLoading,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [selectedToolName, setSelectedToolName] = useState<string | null>(null);
|
||||
|
||||
const getProviderDisplayName = (providerName: string) => {
|
||||
const provider = availableProviders.find((p) => p.provider_name === providerName);
|
||||
return provider?.ui_friendly_name || providerName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm text-gray-600 mb-3 block">
|
||||
Select a search tool to test with live queries.
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="Select a search tool to test"
|
||||
className="w-full"
|
||||
size="large"
|
||||
value={selectedToolName}
|
||||
onChange={setSelectedToolName}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={searchTools.map((tool) => ({
|
||||
label: `${tool.search_tool_name} (${getProviderDisplayName(tool.litellm_params.search_provider)})`,
|
||||
value: tool.search_tool_name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedToolName ? (
|
||||
<SearchToolTester
|
||||
searchToolName={selectedToolName}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<SearchOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
|
||||
<Text className="text-lg font-medium text-gray-600 mb-2">
|
||||
Select a Search Tool to Test
|
||||
</Text>
|
||||
<Text className="text-center text-gray-500 max-w-md">
|
||||
Choose a search tool from the dropdown above to start testing queries and viewing results.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolTestPlayground;
|
||||
|
|
@ -8,14 +8,7 @@ vi.mock("@/utils/dataUtils", () => ({
|
|||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("./SearchToolTester", () => ({
|
||||
SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => (
|
||||
<div data-testid="search-tool-tester">
|
||||
<span>Search Tool Tester for {searchToolName}</span>
|
||||
<span>Access Token: {accessToken}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
describe("SearchToolView", () => {
|
||||
const mockSearchTool: SearchTool = {
|
||||
|
|
@ -260,19 +253,6 @@ describe("SearchToolView", () => {
|
|||
expect(nameCopyButton).not.toHaveClass("text-green-600");
|
||||
});
|
||||
|
||||
it("should render SearchToolTester when accessToken is provided", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render SearchToolTester when accessToken is null", () => {
|
||||
render(<SearchToolView {...defaultProps} accessToken={null} />);
|
||||
expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass correct props to SearchToolTester", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("Access Token: test-token")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Button, Card, Grid, Text, Title } from "@tremor/react";
|
|||
import { Button as AntdButton } from "antd";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
interface SearchToolViewProps {
|
||||
|
|
@ -109,15 +108,6 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search Tool Tester */}
|
||||
<div className="mt-6">
|
||||
{accessToken && (
|
||||
<SearchToolTester
|
||||
searchToolName={searchTool.search_tool_name}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ vi.mock("./CreateSearchTools", () => {
|
|||
return { default: CreateSearchTools };
|
||||
});
|
||||
|
||||
vi.mock("./SearchToolTestPlayground", () => {
|
||||
const SearchToolTestPlayground = () => (
|
||||
<div data-testid="search-tool-test-playground">Test Playground</div>
|
||||
);
|
||||
SearchToolTestPlayground.displayName = "SearchToolTestPlayground";
|
||||
return { default: SearchToolTestPlayground };
|
||||
});
|
||||
|
||||
vi.mock("../common_components/DeleteResourceModal", () => {
|
||||
const DeleteResourceModal = ({
|
||||
isOpen,
|
||||
|
|
@ -132,7 +140,7 @@ describe("SearchTools", () => {
|
|||
it("should render", async () => {
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -171,7 +179,7 @@ describe("SearchTools", () => {
|
|||
it("should show Add New Search Tool button when user is admin", async () => {
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -180,9 +188,9 @@ describe("SearchTools", () => {
|
|||
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add search tool/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open create modal when Add New Search Tool button is clicked", async () => {
|
||||
|
|
@ -190,10 +198,10 @@ describe("SearchTools", () => {
|
|||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add new search tool/i });
|
||||
const addButton = screen.getByRole("button", { name: /add search tool/i });
|
||||
await user.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { teamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Text, Title } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Spin, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form, Input, Modal, Select, Table, Tabs } from "antd";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import {
|
||||
|
|
@ -12,8 +12,10 @@ import {
|
|||
fetchSearchTools,
|
||||
updateSearchTool,
|
||||
} from "../networking";
|
||||
import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
import { searchToolColumns } from "./SearchToolColumn";
|
||||
import SearchToolTestPlayground from "./SearchToolTestPlayground";
|
||||
import { SearchToolView } from "./SearchToolView";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
|
|
@ -23,7 +25,6 @@ interface SearchToolsProps {
|
|||
userID: string | null;
|
||||
}
|
||||
|
||||
|
||||
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const {
|
||||
data: searchTools,
|
||||
|
|
@ -52,6 +53,45 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
|
||||
const availableProviders = providersResponse?.providers || [];
|
||||
|
||||
// For non-admin users, fetch their teams to scope search tools
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
const { data: userTeamsResponse } = useQuery({
|
||||
queryKey: ["userTeamsForSearchTools", userID],
|
||||
queryFn: () => {
|
||||
if (!accessToken || !userID) throw new Error("Missing auth");
|
||||
return teamListCall(accessToken, 1, 100, { userID }) as Promise<TeamsResponse>;
|
||||
},
|
||||
enabled: !!accessToken && !!userID && !isAdmin,
|
||||
});
|
||||
|
||||
// Compute allowed search tool IDs from user's teams
|
||||
const scopedSearchTools = useMemo(() => {
|
||||
if (!searchTools) return [];
|
||||
if (isAdmin) return searchTools;
|
||||
if (!userTeamsResponse?.teams) return [];
|
||||
|
||||
// Collect all search_tool IDs the user's teams grant access to
|
||||
const allowedIds = new Set<string>();
|
||||
let hasWildcard = false;
|
||||
for (const team of userTeamsResponse.teams) {
|
||||
const teamSearchTools = team.object_permission?.search_tools;
|
||||
if (!teamSearchTools) continue;
|
||||
if (teamSearchTools.includes("*")) {
|
||||
hasWildcard = true;
|
||||
break;
|
||||
}
|
||||
for (const id of teamSearchTools) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWildcard) return searchTools;
|
||||
if (allowedIds.size === 0) return [];
|
||||
return searchTools.filter(
|
||||
(tool) => allowedIds.has(tool.search_tool_id || tool.search_tool_name),
|
||||
);
|
||||
}, [searchTools, isAdmin, userTeamsResponse]);
|
||||
|
||||
// State
|
||||
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -87,8 +127,9 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
},
|
||||
handleDelete,
|
||||
availableProviders,
|
||||
isAdmin,
|
||||
),
|
||||
[availableProviders, searchTools, form],
|
||||
[availableProviders, searchTools, form, isAdmin],
|
||||
);
|
||||
|
||||
function handleDelete(toolId: string) {
|
||||
|
|
@ -176,7 +217,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
label="Search Provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
|
||||
<Select placeholder="Select a search provider" notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option key={provider.provider_name} value={provider.provider_name}>
|
||||
{provider.ui_friendly_name}
|
||||
|
|
@ -196,7 +237,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
);
|
||||
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
|
||||
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
|
||||
}
|
||||
|
||||
|
|
@ -221,27 +261,68 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
accessToken={accessToken}
|
||||
availableProviders={availableProviders}
|
||||
/>
|
||||
) : isLoadingTools ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<AntDLoadingSpinner size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
|
||||
<Table
|
||||
bordered
|
||||
dataSource={searchTools || []}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.search_tool_id || record.search_tool_name}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No search tools configured",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
</div>
|
||||
<Table
|
||||
bordered
|
||||
dataSource={scopedSearchTools}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.search_tool_id || record.search_tool_name}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No search tools configured",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6">
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="gap-2 p-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900"><SearchOutlined style={{ marginRight: 8 }} />Search Tools</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure and manage your search providers</p>
|
||||
</div>
|
||||
{isAdminRole(userRole) && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
>
|
||||
Add Search Tool
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="tools"
|
||||
items={[
|
||||
{
|
||||
key: "tools",
|
||||
label: "Search Tools",
|
||||
children: <ToolsTab />,
|
||||
},
|
||||
{
|
||||
key: "test",
|
||||
label: "Test Search Tools",
|
||||
children: (
|
||||
<SearchToolTestPlayground
|
||||
searchTools={scopedSearchTools}
|
||||
availableProviders={availableProviders}
|
||||
isLoading={isLoadingTools}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Search Tool"
|
||||
|
|
@ -273,7 +354,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
setModalVisible={setCreateModalVisible}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title="Edit Search Tool"
|
||||
open={isEditModalVisible}
|
||||
|
|
@ -287,16 +367,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
>
|
||||
{renderEditForm()}
|
||||
</Modal>
|
||||
|
||||
<Title>Search Tools</Title>
|
||||
<Text className="text-tremor-content mt-2">Configure and manage your search providers</Text>
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="mt-4 mb-4" onClick={() => setCreateModalVisible(true)}>
|
||||
+ Add New Search Tool
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ToolsTab />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</span>
|
||||
{blockScope === "team" ? (
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
value={blockTeamId ?? undefined}
|
||||
onChange={(id) => setBlockTeamId(id || null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,20 @@ vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: () => ({
|
||||
data: {
|
||||
pages: [{ teams: [
|
||||
{ team_id: "team-1", team_alias: "Test Team", models: ["gpt-4"] },
|
||||
], total: 1, page: 1, page_size: 50, total_pages: 1 }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAuthorizedUser = (userRole: string, userId: string, premiumUser: boolean) => ({
|
||||
token: "test-token",
|
||||
accessToken: "test-access-token",
|
||||
|
|
@ -227,7 +241,7 @@ describe("AddModelForm", () => {
|
|||
|
||||
const teamSelect = screen.getByRole("combobox");
|
||||
await userEvent.click(teamSelect);
|
||||
await userEvent.click(screen.getByText("Test Team"));
|
||||
await userEvent.click(screen.getByText(/Test Team/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
|
|
@ -246,7 +260,7 @@ describe("AddModelForm", () => {
|
|||
|
||||
const teamSelect = screen.getByRole("combobox");
|
||||
await userEvent.click(teamSelect);
|
||||
await userEvent.click(screen.getByText("Test Team"));
|
||||
await userEvent.click(screen.getByText(/Test Team/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -131,7 +131,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
tooltip="Select the team for which you want to add this model"
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
onChange={(value) => {
|
||||
setTeamAdminSelectedTeam(value);
|
||||
}}
|
||||
|
|
@ -325,7 +324,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
},
|
||||
]}
|
||||
>
|
||||
<TeamDropdown teams={teams} disabled={!premiumUser} />
|
||||
<TeamDropdown disabled={!premiumUser} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{isAdmin && (
|
||||
|
|
|
|||
|
|
@ -723,10 +723,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
|
|||
name="team_id"
|
||||
tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team."
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
loading={!teams}
|
||||
/>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-4" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Member } from "@/components/networking";
|
||||
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { CrownOutlined, InfoCircleOutlined, SafetyCertificateOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import React from "react";
|
||||
|
|
@ -13,6 +13,7 @@ export interface MemberTableProps {
|
|||
onEdit: (member: Member) => void;
|
||||
onDelete: (member: Member) => void;
|
||||
onAddMember?: () => void;
|
||||
onPermissions?: (member: Member) => void;
|
||||
roleColumnTitle?: string;
|
||||
roleTooltip?: string;
|
||||
extraColumns?: ColumnsType<Member>;
|
||||
|
|
@ -26,6 +27,7 @@ export default function MemberTable({
|
|||
onEdit,
|
||||
onDelete,
|
||||
onAddMember,
|
||||
onPermissions,
|
||||
roleColumnTitle = "Role",
|
||||
roleTooltip,
|
||||
extraColumns = [],
|
||||
|
|
@ -83,6 +85,17 @@ export default function MemberTable({
|
|||
render: (_: unknown, record: Member) =>
|
||||
canEdit ? (
|
||||
<Space>
|
||||
{onPermissions && (
|
||||
<Tooltip title="Permissions">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={() => onPermissions(record)}
|
||||
data-testid="permissions-member"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
|
|
|
|||
|
|
@ -1,46 +1,120 @@
|
|||
import React from "react";
|
||||
import React, { useMemo, useState, type UIEvent } from "react";
|
||||
import { Select } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
interface TeamDropdownProps {
|
||||
teams?: Team[] | null;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
/** Callback with the full Team object (or null on clear). */
|
||||
onTeamSelect?: (team: Team | null) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** Filter teams by organization. */
|
||||
organizationId?: string | null;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled, loading }) => {
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onTeamSelect,
|
||||
disabled,
|
||||
organizationId,
|
||||
pageSize = 50,
|
||||
}) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useInfiniteTeams(
|
||||
pageSize,
|
||||
debouncedSearch || undefined,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
const teams = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: Team[] = [];
|
||||
for (const page of data.pages) {
|
||||
for (const team of page.teams) {
|
||||
if (seen.has(team.team_id)) continue;
|
||||
seen.add(team.team_id);
|
||||
result.push(team);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
teams.map((team) => ({
|
||||
label: `${team.team_alias} (${team.team_id})`,
|
||||
value: team.team_id,
|
||||
})),
|
||||
[teams],
|
||||
);
|
||||
|
||||
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const scrollRatio =
|
||||
(target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
setSearchInput(val);
|
||||
setDebouncedSearch(val);
|
||||
};
|
||||
|
||||
const handleChange = (teamId: string | undefined) => {
|
||||
onChange?.(teamId ?? "");
|
||||
if (onTeamSelect) {
|
||||
const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null;
|
||||
onTeamSelect(team);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a team"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
// Get team data from the option key
|
||||
const team = teams?.find((t) => t.team_id === option.key);
|
||||
if (!team) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const teamAlias = (team.team_alias || "").toLowerCase();
|
||||
const teamId = (team.team_id || "").toLowerCase();
|
||||
|
||||
// Search in both team alias and team ID
|
||||
return teamAlias.includes(searchTerm) || teamId.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{teams?.map((team) => (
|
||||
<Select.Option key={team.team_id} value={team.team_id}>
|
||||
<span className="font-medium">{team.team_alias}</span> <span className="text-gray-500">({team.team_id})</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
|
||||
options={options}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
57
ui/litellm-dashboard/src/components/edit_user.test.tsx
Normal file
57
ui/litellm-dashboard/src/components/edit_user.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import EditUserModal from "./edit_user";
|
||||
|
||||
const possibleUIRoles: Record<string, Record<string, string>> = {
|
||||
admin: { ui_label: "Admin", description: "Full access" },
|
||||
user: { ui_label: "User", description: "Standard access" },
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
user_id: "user-123",
|
||||
user_email: "test@example.com",
|
||||
user_role: "user",
|
||||
spend: 10.5,
|
||||
max_budget: 100,
|
||||
};
|
||||
|
||||
describe("EditUserModal", () => {
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
possibleUIRoles,
|
||||
onCancel: vi.fn(),
|
||||
user: mockUser,
|
||||
onSubmit: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<EditUserModal {...defaultProps} />);
|
||||
expect(screen.getByText(/edit user user-123/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when user is null", () => {
|
||||
render(<EditUserModal {...defaultProps} user={null} />);
|
||||
expect(screen.queryByText(/edit user/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the user email field", () => {
|
||||
render(<EditUserModal {...defaultProps} />);
|
||||
expect(screen.getByText("User Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the user role field", () => {
|
||||
render(<EditUserModal {...defaultProps} />);
|
||||
expect(screen.getByText("User Role")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCancel when cancel is triggered", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<EditUserModal {...defaultProps} onCancel={onCancel} />);
|
||||
|
||||
// Click the X close button on the modal
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
77
ui/litellm-dashboard/src/components/email_settings.test.tsx
Normal file
77
ui/litellm-dashboard/src/components/email_settings.test.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import EmailSettings from "./email_settings";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
serviceHealthCheck: vi.fn(),
|
||||
setCallbacksCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./email_events", () => ({
|
||||
EmailEventSettings: () => <div data-testid="email-event-settings" />,
|
||||
}));
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
name: "email",
|
||||
variables: {
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_USERNAME: "user",
|
||||
SMTP_PASSWORD: "pass",
|
||||
SMTP_SENDER_EMAIL: "sender@example.com",
|
||||
TEST_EMAIL_ADDRESS: "test@example.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("EmailSettings", () => {
|
||||
const defaultProps = {
|
||||
accessToken: "test-token",
|
||||
premiumUser: true,
|
||||
alerts: mockAlerts,
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByText("Email Server Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display SMTP fields from alerts", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByText("SMTP_HOST")).toBeInTheDocument();
|
||||
expect(screen.getByText("SMTP_PORT")).toBeInTheDocument();
|
||||
expect(screen.getByText("SMTP_USERNAME")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Save Changes button", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Test Email Alerts button", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByRole("button", { name: /test email alerts/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Required markers for required SMTP fields", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getAllByText(/required \*/i).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("should disable premium fields for non-premium users", () => {
|
||||
render(<EmailSettings {...defaultProps} premiumUser={false} />);
|
||||
// EMAIL_LOGO_URL and EMAIL_SUPPORT_CONTACT should have a sparkle prefix for non-premium
|
||||
// They'll still show but with different rendering
|
||||
expect(screen.getByText("SMTP_HOST")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render EmailEventSettings sub-component", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByTestId("email-event-settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render docs link", () => {
|
||||
render(<EmailSettings {...defaultProps} />);
|
||||
expect(screen.getByText(/litellm docs: email alerts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,16 @@ export interface Team {
|
|||
keys: KeyResponse[];
|
||||
members_with_roles: Member[];
|
||||
spend: number;
|
||||
object_permission?: {
|
||||
object_permission_id?: string;
|
||||
search_tools?: string[];
|
||||
mcp_servers?: string[];
|
||||
mcp_access_groups?: string[];
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
vector_stores?: string[];
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface KeyResponse {
|
||||
|
|
@ -89,6 +99,7 @@ export interface KeyResponse {
|
|||
vector_stores: string[];
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
search_tools?: string[];
|
||||
};
|
||||
access_group_ids?: string[];
|
||||
auto_rotate?: boolean;
|
||||
|
|
|
|||
53
ui/litellm-dashboard/src/components/key_value_input.test.tsx
Normal file
53
ui/litellm-dashboard/src/components/key_value_input.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import KeyValueInput from "./key_value_input";
|
||||
|
||||
describe("KeyValueInput", () => {
|
||||
it("should render", () => {
|
||||
render(<KeyValueInput />);
|
||||
expect(screen.getByRole("button", { name: /add header/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render existing key-value pairs from initial value", () => {
|
||||
render(<KeyValueInput value={{ "X-Api-Key": "secret123" }} />);
|
||||
expect(screen.getByDisplayValue("X-Api-Key")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("secret123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should add a new empty pair when clicking Add Header", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<KeyValueInput />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add header/i }));
|
||||
|
||||
expect(screen.getByPlaceholderText("Header Name")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Header Value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when a key is typed", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<KeyValueInput value={{}} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add header/i }));
|
||||
await user.type(screen.getByPlaceholderText("Header Name"), "Authorization");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ Authorization: "" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove a pair when clicking the remove icon", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<KeyValueInput value={{ key1: "val1" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
|
||||
|
||||
// MinusCircleOutlined renders with role="img"
|
||||
await user.click(screen.getByRole("img", { name: /minus-circle/i }));
|
||||
|
||||
expect(screen.queryByDisplayValue("key1")).not.toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
|
@ -36,8 +36,34 @@ import { all_admin_roles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyT
|
|||
import NewBadge from "./common_components/NewBadge";
|
||||
import type { Organization } from "./networking";
|
||||
import UsageIndicator from "./UsageIndicator";
|
||||
import { serverRootPath } from "./networking";
|
||||
const { Sider } = Layout;
|
||||
|
||||
/**
|
||||
* Pages migrated to path-based routing under (dashboard)/.
|
||||
* Key = legacy page id, Value = route segment.
|
||||
* Keep in sync with MIGRATED_PAGES in (dashboard)/layout.tsx and
|
||||
* LEGACY_REDIRECTS in app/page.tsx.
|
||||
*/
|
||||
const MIGRATED_PAGES: Record<string, string> = {
|
||||
"api-reference": "api-reference",
|
||||
};
|
||||
|
||||
/** Build an absolute href for a migrated page, respecting base URL + serverRootPath. */
|
||||
function migratedHref(routeSegment: string): string {
|
||||
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
|
||||
const trimmed = raw.replace(/^\/+|\/+$/g, "");
|
||||
let base = trimmed ? `/${trimmed}/` : "/";
|
||||
|
||||
if (serverRootPath && serverRootPath !== "/") {
|
||||
const cleanRoot = serverRootPath.replace(/\/+$/, "");
|
||||
const cleanBase = base.replace(/^\/+/, "");
|
||||
base = `${cleanRoot}/${cleanBase}`;
|
||||
}
|
||||
|
||||
return `${base}${routeSegment}`;
|
||||
}
|
||||
|
||||
// Define the props type
|
||||
interface SidebarProps {
|
||||
setPage: (page: string) => void;
|
||||
|
|
@ -379,6 +405,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
|
|||
|
||||
// Navigate to page helper
|
||||
const navigateToPage = (page: string) => {
|
||||
// For migrated pages, just call setPage — the parent layout handles routing
|
||||
if (MIGRATED_PAGES[page]) {
|
||||
setPage(page);
|
||||
return;
|
||||
}
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", page);
|
||||
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
|
||||
|
|
@ -405,9 +436,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
|
|||
</a>
|
||||
);
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("page", page);
|
||||
const href = `?${params.toString()}`;
|
||||
// For migrated pages, generate a path-based href for right-click "Open in new tab"
|
||||
const migratedRoute = MIGRATED_PAGES[page];
|
||||
const href = migratedRoute
|
||||
? migratedHref(migratedRoute)
|
||||
: (() => { const params = new URLSearchParams(window.location.search); params.set("page", page); return `?${params.toString()}`; })();
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,28 @@ vi.mock("../networking", () => ({
|
|||
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeams: () => ({
|
||||
data: [
|
||||
{ team_id: "team-1", team_alias: "Team One" },
|
||||
{ team_id: "team-2", team_alias: "Team Two" },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useInfiniteTeams: () => ({
|
||||
data: {
|
||||
pages: [{ teams: [
|
||||
{ team_id: "team-1", team_alias: "Team One" },
|
||||
{ team_id: "team-2", team_alias: "Team Two" },
|
||||
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
|
||||
useMcpOAuthFlow: () => ({
|
||||
startOAuthFlow: vi.fn(),
|
||||
|
|
@ -100,10 +122,12 @@ describe("CreateMCPServer", () => {
|
|||
expect(screen.getByText("Add New MCP Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when user is not an admin", () => {
|
||||
it("should render for internal users with team selection prompt", () => {
|
||||
render(<CreateMCPServer {...defaultProps} userRole="Internal User" />);
|
||||
|
||||
expect(screen.queryByText("Add New MCP Server")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Add New MCP Server")).toBeInTheDocument();
|
||||
// Internal users without a selected team see a prompt
|
||||
expect(screen.getByText("Select a team to create an MCP server for your team.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show transport type options", async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { createMCPServer, registerMCPServer } from "../networking";
|
||||
import { createMCPServer } from "../networking";
|
||||
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
|
|
@ -17,6 +17,8 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
|||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
|
||||
const asset_logos_folder = "../ui/assets/logos/";
|
||||
export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`;
|
||||
|
|
@ -30,6 +32,7 @@ interface CreateMCPServerProps {
|
|||
availableAccessGroups: string[];
|
||||
prefillData?: DiscoverableMCPServer | null;
|
||||
onBackToDiscovery?: () => void;
|
||||
teamId?: string | null;
|
||||
}
|
||||
|
||||
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
|
||||
|
|
@ -54,6 +57,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
availableAccessGroups,
|
||||
prefillData,
|
||||
onBackToDiscovery,
|
||||
teamId,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -73,6 +77,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
|
||||
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(teamId ?? undefined);
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
|
||||
const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({
|
||||
|
|
@ -385,18 +391,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
payload.credentials = credentialsPayload;
|
||||
}
|
||||
|
||||
// Include team_id when a team is selected
|
||||
if (selectedTeamId) {
|
||||
payload.team_id = selectedTeamId;
|
||||
}
|
||||
|
||||
console.log(`Payload: ${JSON.stringify(payload)}`);
|
||||
|
||||
if (accessToken != null) {
|
||||
const response = isAdmin
|
||||
? await createMCPServer(accessToken, payload)
|
||||
: await registerMCPServer(accessToken, payload);
|
||||
const response = await createMCPServer(accessToken, payload);
|
||||
|
||||
NotificationsManager.success(
|
||||
isAdmin
|
||||
? "MCP Server created successfully"
|
||||
: "MCP Server submitted for admin review"
|
||||
);
|
||||
NotificationsManager.success("MCP Server created successfully");
|
||||
form.resetFields();
|
||||
setCostConfig({});
|
||||
clearTools();
|
||||
|
|
@ -408,9 +413,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
NotificationsManager.fromBackend(
|
||||
isAdmin ? `Error creating MCP Server: ${reason}` : `Error submitting MCP Server: ${reason}`
|
||||
);
|
||||
NotificationsManager.fromBackend(`Error creating MCP Server: ${reason}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -480,6 +483,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
}, [formValues.server_name]);
|
||||
|
||||
// Sync selectedTeamId when the parent teamId prop changes
|
||||
React.useEffect(() => {
|
||||
setSelectedTeamId(teamId ?? undefined);
|
||||
}, [teamId]);
|
||||
|
||||
// Clear formValues when modal closes to reset child components
|
||||
React.useEffect(() => {
|
||||
if (!isModalVisible) {
|
||||
|
|
@ -514,7 +522,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}}
|
||||
/>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isAdmin ? "Add New MCP Server" : "Submit MCP Server for Review"}
|
||||
Add New MCP Server
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -537,12 +545,27 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
{!isAdmin && (
|
||||
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800">
|
||||
Your submission will be sent for admin review before it becomes active.
|
||||
{" "}Note: the request must be made with a team-scoped API key.
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Team
|
||||
<Tooltip title="Assign this MCP server to a team. The server will be added to the team's permissions automatically.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TeamDropdown
|
||||
value={selectedTeamId}
|
||||
onChange={(value) => setSelectedTeamId(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!isAdmin && !selectedTeamId ? (
|
||||
<div className="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800">
|
||||
Select a team to create an MCP server for your team.
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
|
|
@ -994,12 +1017,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isLoading}>
|
||||
<Button variant="primary" loading={isLoading} disabled={!isAdmin && !selectedTeamId}>
|
||||
{isLoading ? "Creating..." : "Add MCP Server"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Icon } from "@tremor/react";
|
|||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { Tooltip } from "antd";
|
||||
import { CheckOutlined } from "@ant-design/icons";
|
||||
import { CheckOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
const HealthStatusBadge: React.FC<{
|
||||
server: MCPServer;
|
||||
|
|
@ -92,10 +92,12 @@ export const mcpServerColumns = (
|
|||
onByokConnect?: (server: MCPServer) => void,
|
||||
onRecheckHealth?: (serverId: string) => void,
|
||||
recheckingServerIds?: Set<string>,
|
||||
teamAliasMap?: Map<string, string>,
|
||||
): ColumnDef<MCPServer>[] => [
|
||||
{
|
||||
accessorKey: "server_id",
|
||||
header: "Server ID",
|
||||
size: 90,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
|
|
@ -109,12 +111,13 @@ export const mcpServerColumns = (
|
|||
{
|
||||
accessorKey: "server_name",
|
||||
header: "Name",
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const logoUrl = row.original.mcp_info?.logo_url;
|
||||
const name = row.original.server_name;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
|
|
@ -123,31 +126,53 @@ export const mcpServerColumns = (
|
|||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{name}</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "alias",
|
||||
header: "Alias",
|
||||
accessorKey: "team_id",
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
Team (Owner)
|
||||
<Tooltip title="Shows the team that owns this MCP server. You can only see servers owned by teams where you have the mcp:read permission.">
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help" style={{ fontSize: 12 }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const tid = row.original.team_id;
|
||||
if (!tid) {
|
||||
return <span className="text-xs text-gray-400 italic">Global</span>;
|
||||
}
|
||||
const alias = teamAliasMap?.get(tid);
|
||||
return (
|
||||
<Tooltip title={tid}>
|
||||
<span className="text-sm truncate block">{alias || tid.slice(0, 8) + "..."}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "url",
|
||||
header: "URL",
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.url;
|
||||
if (!url) {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
const { maskedUrl } = getMaskedAndFullUrl(url);
|
||||
return <span className="font-mono text-sm">{maskedUrl}</span>;
|
||||
return <span className="font-mono text-sm truncate block">{maskedUrl}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "transport",
|
||||
header: "Transport",
|
||||
size: 80,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const transport = row.original.transport || "http";
|
||||
|
|
@ -161,22 +186,10 @@ export const mcpServerColumns = (
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "auth_type",
|
||||
header: "Auth Type",
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const authType = (getValue() as string) || "none";
|
||||
return (
|
||||
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200">
|
||||
{authType}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health_status",
|
||||
header: "Health Status",
|
||||
header: "Health",
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<HealthStatusBadge
|
||||
server={row.original}
|
||||
|
|
@ -189,6 +202,7 @@ export const mcpServerColumns = (
|
|||
{
|
||||
id: "mcp_access_groups",
|
||||
header: "Access Groups",
|
||||
size: 130,
|
||||
cell: ({ row }) => {
|
||||
const groups = row.original.mcp_access_groups;
|
||||
if (Array.isArray(groups) && groups.length > 0) {
|
||||
|
|
@ -196,8 +210,8 @@ export const mcpServerColumns = (
|
|||
const joined = groups.join(", ");
|
||||
return (
|
||||
<Tooltip title={joined}>
|
||||
<div className="flex items-center gap-1 max-w-[200px]">
|
||||
<span className="inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[140px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[100px]">
|
||||
{groups[0]}
|
||||
</span>
|
||||
{groups.length > 1 && (
|
||||
|
|
@ -213,7 +227,8 @@ export const mcpServerColumns = (
|
|||
},
|
||||
{
|
||||
id: "available_on_public_internet",
|
||||
header: "Network Access",
|
||||
header: "Network",
|
||||
size: 80,
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.available_on_public_internet;
|
||||
return isPublic ? (
|
||||
|
|
@ -232,6 +247,7 @@ export const mcpServerColumns = (
|
|||
{
|
||||
header: "Created",
|
||||
accessorKey: "created_at",
|
||||
size: 90,
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -245,25 +261,10 @@ export const mcpServerColumns = (
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Updated",
|
||||
accessorKey: "updated_at",
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const server = row.original;
|
||||
if (!server.updated_at) return <span className="text-xs text-gray-400">—</span>;
|
||||
const date = new Date(server.updated_at);
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString()}>
|
||||
<span className="text-xs text-gray-600">{date.toLocaleDateString()}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "byok_credential",
|
||||
header: "Credential",
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const server = row.original;
|
||||
if (!server.is_byok) {
|
||||
|
|
@ -299,6 +300,7 @@ export const mcpServerColumns = (
|
|||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="Edit">
|
||||
|
|
|
|||
|
|
@ -121,8 +121,6 @@ describe("MCPServers", () => {
|
|||
// Verify the mocked server data is rendered in the table
|
||||
expect(getByText("Test Server 1")).toBeInTheDocument();
|
||||
expect(getByText("Test Server 2")).toBeInTheDocument();
|
||||
expect(getByText("test-server-1")).toBeInTheDocument();
|
||||
expect(getByText("test-server-2")).toBeInTheDocument();
|
||||
|
||||
// Verify the API was called
|
||||
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock
|
||||
|
|
@ -306,8 +304,9 @@ describe("MCPServers", () => {
|
|||
expect(screen.getByText("Team B Server")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
|
||||
|
||||
// Find the team select dropdown by looking for the "Team" label
|
||||
const teamLabel = screen.getByText("Team");
|
||||
// Find the team filter dropdown (not the "Team (Owner)" column header)
|
||||
const teamLabels = screen.getAllByText("Team");
|
||||
const teamLabel = teamLabels.find((el) => el.classList.contains("text-sm") && el.classList.contains("font-medium") && el.classList.contains("text-gray-600"))!;
|
||||
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
|
||||
expect(teamSelectContainer).toBeTruthy();
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import MCPConnect from "./mcp_connect";
|
|||
import { mcpServerColumns } from "./mcp_server_columns";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
|
||||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
import MCPDiscovery from "./mcp_discovery";
|
||||
|
|
@ -26,7 +27,7 @@ const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
|||
const { Option } = Select;
|
||||
|
||||
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
|
||||
const { data: mcpServers, isLoading: isLoadingServers, error: mcpServersError, refetch } = useMCPServers();
|
||||
|
||||
// Fetch health status for all servers
|
||||
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
|
||||
|
|
@ -63,6 +64,16 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
const [isDeletingServer, setIsDeletingServer] = useState(false);
|
||||
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
|
||||
const isInternalUser = userRole === "Internal User";
|
||||
const { data: allTeams } = useTeams();
|
||||
const teamAliasMap = React.useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (allTeams) {
|
||||
for (const t of allTeams) {
|
||||
if (t.team_alias) map.set(t.team_id, t.team_alias);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allTeams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
|
|
@ -171,8 +182,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
(server: MCPServer) => setByokModalServer(server),
|
||||
recheckServerHealth,
|
||||
recheckingServerIds,
|
||||
teamAliasMap,
|
||||
),
|
||||
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds],
|
||||
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, teamAliasMap],
|
||||
);
|
||||
|
||||
function handleDelete(server_id: string) {
|
||||
|
|
@ -237,11 +249,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
|
||||
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6">
|
||||
<Modal
|
||||
|
|
@ -284,7 +291,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
</div>
|
||||
</Modal>
|
||||
<CreateMCPServer
|
||||
userRole={userRole}
|
||||
userRole={userRole ?? ""}
|
||||
accessToken={accessToken}
|
||||
onCreateSuccess={handleCreateSuccess}
|
||||
isModalVisible={isModalVisible}
|
||||
|
|
@ -296,6 +303,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
setPrefillData(null);
|
||||
setDiscoveryVisible(true);
|
||||
}}
|
||||
teamId={selectedTeam !== "all" && selectedTeam !== "personal" ? selectedTeam : null}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -310,23 +318,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
|
||||
+ Add New MCP Server
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminRole(userRole) && (
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
setPrefillData(null);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
+ Submit MCP Server
|
||||
</Button>
|
||||
)}
|
||||
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
|
||||
+ Add New MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<MCPDiscovery
|
||||
|
|
@ -351,7 +345,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<Tab>Connect</Tab>
|
||||
<Tab>Semantic Filter</Tab>
|
||||
<Tab>Network Settings</Tab>
|
||||
{isAdminRole(userRole) && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
|
||||
{isAdminRole(userRole ?? "") && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
|
|
@ -361,7 +355,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
key={selectedServerId}
|
||||
mcpServer={selectedServer}
|
||||
onBack={handleBack}
|
||||
isProxyAdmin={isAdminRole(userRole)}
|
||||
isProxyAdmin={isAdminRole(userRole ?? "")}
|
||||
isEditing={editServer}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
|
|
@ -412,16 +406,38 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-6">
|
||||
{mcpServersError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center">
|
||||
<h3 className="text-lg font-semibold text-red-800 mb-2">Unable to load MCP servers</h3>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
{mcpServersError.message?.includes("403")
|
||||
? "You do not have permission to view MCP servers."
|
||||
: `Error: ${mcpServersError.message}`}
|
||||
</p>
|
||||
<div className="text-sm text-red-600 bg-red-100 rounded-md p-4 inline-block text-left">
|
||||
<p className="font-medium mb-2">To resolve this, ask your team admin or proxy admin to:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Go to <span className="font-mono">Teams</span> and select your team</li>
|
||||
<li>Find your user in the team members list</li>
|
||||
<li>Add the <span className="font-mono bg-red-200 px-1 rounded">mcp:read</span> permission to your user</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={filteredServers}
|
||||
columns={columns}
|
||||
renderSubComponent={() => <div></div>}
|
||||
getRowCanExpand={() => false}
|
||||
isLoading={isLoadingServers}
|
||||
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
|
||||
noDataMessage={isInternalUser
|
||||
? "No MCP servers available. Your team may not have any MCP servers assigned, or you may need the mcp:read permission. Contact your team admin."
|
||||
: "No MCP servers configured. Click '+ Add New MCP Server' to get started."
|
||||
}
|
||||
loadingMessage="Loading MCP servers..."
|
||||
enableSorting={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -435,7 +451,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<TabPanel>
|
||||
<MCPNetworkSettings accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
{isAdminRole(userRole) && (
|
||||
{isAdminRole(userRole ?? "") && (
|
||||
<TabPanel>
|
||||
<MCPSubmissionsTab accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export interface MCPServer {
|
|||
created_by: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
team_id?: string | null;
|
||||
extra_headers?: string[] | null;
|
||||
static_headers?: Record<string, string> | null;
|
||||
status?: "healthy" | "unhealthy" | "unknown";
|
||||
|
|
|
|||
93
ui/litellm-dashboard/src/components/model_filters.test.tsx
Normal file
93
ui/litellm-dashboard/src/components/model_filters.test.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import ModelFilters from "./model_filters";
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
model_group: "gpt-4",
|
||||
providers: ["openai"],
|
||||
mode: "chat",
|
||||
supports_function_calling: true,
|
||||
supports_vision: false,
|
||||
supports_parallel_function_calling: false,
|
||||
is_public_model_group: true,
|
||||
},
|
||||
{
|
||||
model_group: "claude-3",
|
||||
providers: ["anthropic"],
|
||||
mode: "chat",
|
||||
supports_function_calling: true,
|
||||
supports_vision: true,
|
||||
supports_parallel_function_calling: false,
|
||||
is_public_model_group: true,
|
||||
},
|
||||
{
|
||||
model_group: "whisper-1",
|
||||
providers: ["openai"],
|
||||
mode: "audio_transcription",
|
||||
supports_function_calling: false,
|
||||
supports_vision: false,
|
||||
supports_parallel_function_calling: false,
|
||||
is_public_model_group: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ModelFilters", () => {
|
||||
const defaultProps = {
|
||||
modelHubData: mockData,
|
||||
onFilteredDataChange: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText(/search model names/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter models by search term", async () => {
|
||||
const onFilteredDataChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ModelFilters {...defaultProps} onFilteredDataChange={onFilteredDataChange} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/search model names/i), "gpt");
|
||||
|
||||
expect(onFilteredDataChange).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ model_group: "gpt-4" })])
|
||||
);
|
||||
});
|
||||
|
||||
it("should show provider dropdown with available providers", () => {
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
const providerSelect = screen.getByDisplayValue("All Providers");
|
||||
expect(providerSelect).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show mode dropdown with available modes", () => {
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
expect(screen.getByDisplayValue("All Modes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show features dropdown", () => {
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
expect(screen.getByDisplayValue("All Features")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Clear Filters button when a filter is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/search model names/i), "gpt");
|
||||
|
||||
expect(screen.getByText("Clear Filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Clear Filters button when no filters are active", () => {
|
||||
render(<ModelFilters {...defaultProps} />);
|
||||
expect(screen.queryByText("Clear Filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render without Card wrapper when showFiltersCard is false", () => {
|
||||
const { container } = render(<ModelFilters {...defaultProps} showFiltersCard={false} />);
|
||||
// When showFiltersCard=false, it renders a plain div instead of a Card
|
||||
expect(container.querySelector(".tremor-Card-root")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -3753,6 +3753,7 @@ export interface Member {
|
|||
max_budget_in_team?: number | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
extra_permissions?: string[] | null;
|
||||
}
|
||||
|
||||
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
|
||||
|
|
@ -3892,6 +3893,9 @@ export const teamMemberUpdateCall = async (
|
|||
if (formValues.rpm_limit !== undefined && formValues.rpm_limit !== null) {
|
||||
requestBody.rpm_limit = formValues.rpm_limit;
|
||||
}
|
||||
if (formValues.extra_permissions !== undefined) {
|
||||
requestBody.extra_permissions = formValues.extra_permissions;
|
||||
}
|
||||
|
||||
console.log("Final request body:", requestBody);
|
||||
|
||||
|
|
@ -6570,6 +6574,41 @@ export const fetchMCPClientIp = async (accessToken: string): Promise<string | nu
|
|||
}
|
||||
};
|
||||
|
||||
export interface AvailablePermission {
|
||||
value: string;
|
||||
label: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
export const fetchAvailableTeamMemberPermissions = async (accessToken: string): Promise<AvailablePermission[]> => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/available_permissions`
|
||||
: `/team/available_permissions`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: HTTP_REQUEST.GET,
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch available permissions:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createMCPServer = async (
|
||||
accessToken: string,
|
||||
formValues: Record<string, any>, // Assuming formValues is an object
|
||||
|
|
@ -7288,12 +7327,11 @@ export const getTeamPermissionsCall = async (accessToken: string, teamId: string
|
|||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
console.error("Available permissions fetch failed:", errorMessage);
|
||||
return { all_available_permissions: [], team_member_permissions: [] };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Team permissions response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get team permissions:", error);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Text } from "@tremor/react";
|
|||
import VectorStorePermissions from "./permissions/VectorStorePermissions";
|
||||
import MCPServerPermissions from "./permissions/MCPServerPermissions";
|
||||
import AgentPermissions from "./permissions/AgentPermissions";
|
||||
import SearchToolPermissions from "./permissions/SearchToolPermissions";
|
||||
|
||||
interface ObjectPermission {
|
||||
object_permission_id: string;
|
||||
|
|
@ -12,6 +13,7 @@ interface ObjectPermission {
|
|||
vector_stores: string[];
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
search_tools?: string[];
|
||||
}
|
||||
|
||||
interface ObjectPermissionsViewProps {
|
||||
|
|
@ -33,21 +35,26 @@ export function ObjectPermissionsView({
|
|||
const mcpToolPermissions = objectPermission?.mcp_tool_permissions || {};
|
||||
const agents = objectPermission?.agents || [];
|
||||
const agentAccessGroups = objectPermission?.agent_access_groups || [];
|
||||
const searchTools = objectPermission?.search_tools || [];
|
||||
|
||||
const content = (
|
||||
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
|
||||
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" : "space-y-4"}>
|
||||
<VectorStorePermissions vectorStores={vectorStores} accessToken={accessToken} />
|
||||
<MCPServerPermissions
|
||||
mcpServers={mcpServers}
|
||||
mcpAccessGroups={mcpAccessGroups}
|
||||
<MCPServerPermissions
|
||||
mcpServers={mcpServers}
|
||||
mcpAccessGroups={mcpAccessGroups}
|
||||
mcpToolPermissions={mcpToolPermissions}
|
||||
accessToken={accessToken}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<AgentPermissions
|
||||
agents={agents}
|
||||
agentAccessGroups={agentAccessGroups}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<SearchToolPermissions
|
||||
searchTools={searchTools}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -57,7 +64,7 @@ export function ObjectPermissionsView({
|
|||
<div className="flex items-center gap-2 mb-6">
|
||||
<div>
|
||||
<Text className="font-semibold text-gray-900">Object Permissions</Text>
|
||||
<Text className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</Text>
|
||||
<Text className="text-xs text-gray-500">Access control for Vector Stores, MCP Servers, and Search Tools</Text>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
|
|
|
|||
69
ui/litellm-dashboard/src/components/onboarding_link.test.tsx
Normal file
69
ui/litellm-dashboard/src/components/onboarding_link.test.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import OnboardingModal, { InvitationLink } from "./onboarding_link";
|
||||
|
||||
const baseLinkData: InvitationLink = {
|
||||
id: "inv-123",
|
||||
user_id: "user-456",
|
||||
is_accepted: false,
|
||||
accepted_at: null,
|
||||
expires_at: new Date("2026-04-01"),
|
||||
created_at: new Date("2026-03-01"),
|
||||
created_by: "admin",
|
||||
updated_at: new Date("2026-03-01"),
|
||||
updated_by: "admin",
|
||||
has_user_setup_sso: false,
|
||||
};
|
||||
|
||||
describe("OnboardingModal", () => {
|
||||
const defaultProps = {
|
||||
isInvitationLinkModalVisible: true,
|
||||
setIsInvitationLinkModalVisible: vi.fn(),
|
||||
baseUrl: "https://proxy.example.com",
|
||||
invitationLinkData: baseLinkData,
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<OnboardingModal {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the user ID", () => {
|
||||
render(<OnboardingModal {...defaultProps} />);
|
||||
expect(screen.getByText("user-456")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should generate an invitation URL with invitation_id param", () => {
|
||||
render(<OnboardingModal {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("https://proxy.example.com/ui?invitation_id=inv-123")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show reset password title when modalType is resetPassword", () => {
|
||||
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
|
||||
expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should append action=reset_password to URL for resetPassword type", () => {
|
||||
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
|
||||
expect(
|
||||
screen.getByText("https://proxy.example.com/ui?invitation_id=inv-123&action=reset_password")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show plain UI URL when user has SSO setup", () => {
|
||||
const ssoLink = { ...baseLinkData, has_user_setup_sso: true };
|
||||
render(<OnboardingModal {...defaultProps} invitationLinkData={ssoLink} />);
|
||||
expect(screen.getByText("https://proxy.example.com/ui")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show copy invitation link button by default", () => {
|
||||
render(<OnboardingModal {...defaultProps} />);
|
||||
expect(screen.getByRole("button", { name: /copy invitation link/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show copy password reset link button for resetPassword type", () => {
|
||||
render(<OnboardingModal {...defaultProps} modalType="resetPassword" />);
|
||||
expect(screen.getByRole("button", { name: /copy password reset link/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -154,7 +154,11 @@ vi.mock("antd", () => {
|
|||
const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) =>
|
||||
React.createElement("button", { ...props, type: htmlType ?? props.type }, children);
|
||||
|
||||
const Alert = ({ message, description, ...props }: any) =>
|
||||
React.createElement("div", { role: "alert", ...props }, message, description);
|
||||
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
|
|
@ -173,6 +177,21 @@ vi.mock("antd", () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: () => ({
|
||||
data: {
|
||||
pages: [{ teams: [
|
||||
{ team_id: "team-1", team_alias: "Team One" },
|
||||
{ team_id: "team-2", team_alias: "Team Two" },
|
||||
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyCreateCall: mockKeyCreateCall,
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
|
|||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
|
||||
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import { Alert, Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from "../networking";
|
||||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import SearchToolSelector from "../SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import { simplifyKeyGenerateError } from "./utils";
|
||||
|
||||
|
|
@ -499,6 +500,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
// Always send search_tools to ensure the permission record is created.
|
||||
// Empty array = no access (least privilege for new keys).
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
|
||||
delete formValues.allowed_search_tool_ids;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.aliases = JSON.stringify(modelAliases);
|
||||
|
|
@ -806,19 +815,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
help={keyOwner === "service_account" ? "required" : ""}
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams}
|
||||
disabled={selectedProjectId !== null}
|
||||
loading={!teams}
|
||||
onChange={(teamId) => {
|
||||
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
|
||||
setSelectedCreateKeyTeam(selectedTeam);
|
||||
organizationId={selectedOrganizationId}
|
||||
onTeamSelect={(team) => {
|
||||
setSelectedCreateKeyTeam(team);
|
||||
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) {
|
||||
if (team?.organization_id) {
|
||||
setSelectedOrganizationId(team.organization_id);
|
||||
form.setFieldValue("organization_id", team.organization_id);
|
||||
} else if (!team) {
|
||||
setSelectedOrganizationId(null);
|
||||
form.setFieldValue("organization_id", undefined);
|
||||
}
|
||||
|
|
@ -1424,6 +1430,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New keys have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this key can access. New keys default to no access — explicitly grant access to specific search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_search_tool_ids"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
|
||||
value={form.getFieldValue("allowed_search_tool_ids")}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
allowedSearchToolIds={selectedCreateKeyTeam ? (selectedCreateKeyTeam.object_permission?.search_tools ?? []) : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
{premiumUser ? (
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import PassThroughInfoView from "./pass_through_info";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
getProxyBaseUrl: () => "https://proxy.example.com",
|
||||
updatePassThroughEndpoint: vi.fn(),
|
||||
deletePassThroughEndpointsCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./common_components/PassThroughSecuritySection", () => ({
|
||||
default: () => <div data-testid="security-section" />,
|
||||
}));
|
||||
|
||||
vi.mock("./common_components/PassThroughGuardrailsSection", () => ({
|
||||
default: () => <div data-testid="guardrails-section" />,
|
||||
}));
|
||||
|
||||
const mockEndpoint = {
|
||||
id: "ep-123",
|
||||
path: "/custom/api",
|
||||
target: "https://target.example.com",
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
include_subpath: true,
|
||||
cost_per_request: 0.01,
|
||||
auth: true,
|
||||
methods: ["GET", "POST"],
|
||||
};
|
||||
|
||||
describe("PassThroughInfoView", () => {
|
||||
const defaultProps = {
|
||||
endpointData: mockEndpoint,
|
||||
onClose: vi.fn(),
|
||||
accessToken: "test-token",
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getByText(/pass through endpoint: \/custom\/api/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the endpoint ID", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getByText("ep-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Include Subpath badge when enabled", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getAllByText("Include Subpath").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should show Auth Required badge when auth is true", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getByText("Auth Required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display HTTP methods as badges", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getByText("GET")).toBeInTheDocument();
|
||||
expect(screen.getByText("POST")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'All HTTP methods supported' when no methods specified", () => {
|
||||
const noMethodEndpoint = { ...mockEndpoint, methods: [] };
|
||||
render(<PassThroughInfoView {...defaultProps} endpointData={noMethodEndpoint} />);
|
||||
expect(screen.getByText("All HTTP methods supported")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show headers count badge", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} />);
|
||||
expect(screen.getByText("1 headers configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when Back button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PassThroughInfoView {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show Settings tab for admin users", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} isAdmin={true} />);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Settings tab for non-admin users", () => {
|
||||
render(<PassThroughInfoView {...defaultProps} isAdmin={false} />);
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Text, Badge } from "@tremor/react";
|
||||
import { SearchIcon } from "@heroicons/react/outline";
|
||||
import { fetchSearchTools } from "../networking";
|
||||
|
||||
interface SearchToolDetails {
|
||||
search_tool_id: string;
|
||||
search_tool_name?: string;
|
||||
}
|
||||
|
||||
interface SearchToolPermissionsProps {
|
||||
searchTools: string[];
|
||||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
export function SearchToolPermissions({ searchTools, accessToken }: SearchToolPermissionsProps) {
|
||||
const [searchToolDetails, setSearchToolDetails] = useState<SearchToolDetails[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSearchTools = async () => {
|
||||
if (!accessToken || searchTools.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetchSearchTools(accessToken);
|
||||
if (response.search_tools) {
|
||||
setSearchToolDetails(
|
||||
response.search_tools.map((tool: any) => ({
|
||||
search_tool_id: tool.search_tool_id,
|
||||
search_tool_name: tool.search_tool_name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching search tools:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSearchTools();
|
||||
}, [accessToken, searchTools.length]);
|
||||
|
||||
const getSearchToolDisplayName = (toolId: string) => {
|
||||
if (toolId === "*") return "All Search Tools (wildcard)";
|
||||
const toolDetail = searchToolDetails.find((tool) => tool.search_tool_id === toolId);
|
||||
if (toolDetail) {
|
||||
return `${toolDetail.search_tool_name || toolDetail.search_tool_id} (${toolDetail.search_tool_id})`;
|
||||
}
|
||||
return toolId;
|
||||
};
|
||||
|
||||
// Check if this is a wildcard permission (legacy/migrated)
|
||||
const isWildcard = searchTools.length === 1 && searchTools[0] === "*";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchIcon className="h-4 w-4 text-amber-600" />
|
||||
<Text className="font-semibold text-gray-900">Search Tools</Text>
|
||||
<Badge color="amber" size="xs">
|
||||
{isWildcard ? "All" : searchTools.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isWildcard ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200">
|
||||
<Text className="text-amber-700 text-sm">
|
||||
All search tools accessible (migrated permission — consider restricting to specific tools)
|
||||
</Text>
|
||||
</div>
|
||||
) : searchTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium"
|
||||
>
|
||||
{getSearchToolDisplayName(tool)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<SearchIcon className="h-4 w-4 text-gray-400" />
|
||||
<Text className="text-gray-500 text-sm">No search tools configured</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchToolPermissions;
|
||||
|
|
@ -6,8 +6,13 @@ import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
|
|||
import { DotsVerticalIcon } from "@heroicons/react/solid";
|
||||
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
|
||||
import { Guardrail } from "../guardrails/types";
|
||||
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
|
||||
import { testPipelineCall } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import {
|
||||
usePolicyVersions,
|
||||
useCreatePolicyVersion,
|
||||
useUpdatePolicyVersionStatus,
|
||||
} from "@/app/(dashboard)/hooks/policies/usePolicyVersions";
|
||||
import {
|
||||
getComplianceDatasetPrompts,
|
||||
getFrameworks,
|
||||
|
|
@ -1289,10 +1294,6 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
const [pipeline, setPipeline] = useState<GuardrailPipeline>(
|
||||
() => derivePipelineFromPolicy(editingPolicy)
|
||||
);
|
||||
const [versions, setVersions] = useState<Policy[]>([]);
|
||||
const [isVersionsLoading, setIsVersionsLoading] = useState(false);
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
|
||||
// Sync local state when editingPolicy changes (e.g. user switched version)
|
||||
React.useEffect(() => {
|
||||
|
|
@ -1301,45 +1302,28 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
setPipeline(derivePipelineFromPolicy(editingPolicy));
|
||||
}, [editingPolicy?.policy_id, editingPolicy?.policy_name, editingPolicy?.description, editingPolicy?.pipeline, editingPolicy?.guardrails_add]);
|
||||
|
||||
// Fetch versions when editing an existing policy by name
|
||||
React.useEffect(() => {
|
||||
if (!showVersionsSidebar || !editingPolicy?.policy_name || !accessToken) {
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsVersionsLoading(true);
|
||||
listPolicyVersions(accessToken, editingPolicy.policy_name)
|
||||
.then((res) => {
|
||||
if (!cancelled) setVersions(res.versions || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setVersions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsVersionsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showVersionsSidebar, editingPolicy?.policy_name, accessToken]);
|
||||
// ── Version management via React Query hooks ──────────────────────────────
|
||||
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: isVersionsLoading,
|
||||
} = usePolicyVersions({
|
||||
policyName: editingPolicy?.policy_name,
|
||||
enabled: showVersionsSidebar,
|
||||
});
|
||||
const versions = versionsData?.versions ?? []; // versionsData?.versions is Policy[] after select, fallback covers undefined data
|
||||
|
||||
const createVersionMutation = useCreatePolicyVersion(editingPolicy?.policy_name);
|
||||
const updateStatusMutation = useUpdatePolicyVersionStatus(editingPolicy?.policy_name);
|
||||
|
||||
const handleNewVersion = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_name) return;
|
||||
setIsCreatingVersion(true);
|
||||
let newPolicy: Policy;
|
||||
try {
|
||||
const newPolicy = await createPolicyVersion(accessToken, editingPolicy.policy_name);
|
||||
NotificationsManager.success("New draft version created");
|
||||
onVersionCreated?.(newPolicy);
|
||||
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name);
|
||||
setVersions(list.versions ?? []);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to create version: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
newPolicy = await createVersionMutation.mutateAsync();
|
||||
} catch {
|
||||
return; // Notification already shown by onError in the mutation hook
|
||||
}
|
||||
onVersionCreated?.(newPolicy);
|
||||
};
|
||||
|
||||
const handleSelectVersion = (policy: Policy) => {
|
||||
|
|
@ -1347,41 +1331,31 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
};
|
||||
|
||||
const handlePublishVersion = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_id) return;
|
||||
setIsUpdatingStatus(true);
|
||||
if (!editingPolicy?.policy_id) return;
|
||||
let updated: Policy;
|
||||
try {
|
||||
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "published");
|
||||
NotificationsManager.success(
|
||||
"Version published. You can test it in the Playground by selecting this version in the Policies dropdown."
|
||||
);
|
||||
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
|
||||
setVersions(list.versions ?? []);
|
||||
onVersionStatusUpdated?.(updated);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to publish: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
updated = await updateStatusMutation.mutateAsync({
|
||||
policyId: editingPolicy.policy_id,
|
||||
status: "published",
|
||||
});
|
||||
} catch {
|
||||
return; // Notification already shown by onError in the mutation hook
|
||||
}
|
||||
onVersionStatusUpdated?.(updated);
|
||||
};
|
||||
|
||||
const handlePromoteToProduction = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_id) return;
|
||||
setIsUpdatingStatus(true);
|
||||
if (!editingPolicy?.policy_id) return;
|
||||
let updated: Policy;
|
||||
try {
|
||||
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "production");
|
||||
NotificationsManager.success("Version promoted to production");
|
||||
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
|
||||
setVersions(list.versions ?? []);
|
||||
onVersionStatusUpdated?.(updated);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to promote to production: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
updated = await updateStatusMutation.mutateAsync({
|
||||
policyId: editingPolicy.policy_id,
|
||||
status: "production",
|
||||
});
|
||||
} catch {
|
||||
return; // Notification already shown by onError in the mutation hook
|
||||
}
|
||||
onVersionStatusUpdated?.(updated);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -1541,8 +1515,8 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
accessToken={accessToken}
|
||||
versions={versions}
|
||||
isLoading={isVersionsLoading}
|
||||
isCreatingVersion={isCreatingVersion}
|
||||
isUpdatingStatus={isUpdatingStatus}
|
||||
isCreatingVersion={createVersionMutation.isPending}
|
||||
isUpdatingStatus={updateStatusMutation.isPending}
|
||||
onNewVersion={handleNewVersion}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onPublish={handlePublishVersion}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import QueryParamInput from "./query_param_input";
|
||||
|
||||
describe("QueryParamInput", () => {
|
||||
it("should render", () => {
|
||||
render(<QueryParamInput />);
|
||||
expect(screen.getByRole("button", { name: /add query parameter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render existing pairs from initial value", () => {
|
||||
render(<QueryParamInput value={{ version: "v1" }} />);
|
||||
expect(screen.getByDisplayValue("version")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("v1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should add a new empty pair when clicking Add Query Parameter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QueryParamInput />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add query parameter/i }));
|
||||
|
||||
expect(screen.getByPlaceholderText(/parameter name/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/parameter value/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when a value is typed", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<QueryParamInput value={{}} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add query parameter/i }));
|
||||
await user.type(screen.getByPlaceholderText(/parameter name/i), "limit");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ limit: "" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove a pair when clicking the remove icon", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<QueryParamInput value={{ foo: "bar" }} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("img", { name: /minus-circle/i }));
|
||||
|
||||
expect(screen.queryByDisplayValue("foo")).not.toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { ResponseTimeIndicator } from "./response_time_indicator";
|
||||
|
||||
describe("ResponseTimeIndicator", () => {
|
||||
it("should render", () => {
|
||||
render(<ResponseTimeIndicator responseTimeMs={150} />);
|
||||
expect(screen.getByText("150ms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when responseTimeMs is null", () => {
|
||||
const { container } = render(<ResponseTimeIndicator responseTimeMs={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should round the displayed time to the nearest integer", () => {
|
||||
render(<ResponseTimeIndicator responseTimeMs={123.456} />);
|
||||
expect(screen.getByText("123ms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 0ms for a zero response time", () => {
|
||||
render(<ResponseTimeIndicator responseTimeMs={0} />);
|
||||
expect(screen.getByText("0ms")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
ui/litellm-dashboard/src/components/route_preview.test.tsx
Normal file
57
ui/litellm-dashboard/src/components/route_preview.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import RoutePreview from "./route_preview";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
getProxyBaseUrl: () => "https://proxy.example.com",
|
||||
}));
|
||||
|
||||
describe("RoutePreview", () => {
|
||||
it("should render", () => {
|
||||
render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
|
||||
);
|
||||
expect(screen.getByText("Route Preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when pathValue is empty", () => {
|
||||
const { container } = render(
|
||||
<RoutePreview pathValue="" targetValue="https://target.com" includeSubpath={false} />
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should return null when targetValue is empty", () => {
|
||||
const { container } = render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="" includeSubpath={false} />
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should display the full proxy URL for the endpoint", () => {
|
||||
render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
|
||||
);
|
||||
expect(screen.getByText("https://proxy.example.com/api/v1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display target URL in the forwards-to section", () => {
|
||||
render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
|
||||
);
|
||||
expect(screen.getByText("https://target.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show subpath routing info when includeSubpath is true", () => {
|
||||
render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={true} />
|
||||
);
|
||||
expect(screen.getByText("With subpaths:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the 'enable subpaths' hint when includeSubpath is false", () => {
|
||||
render(
|
||||
<RoutePreview pathValue="/api/v1" targetValue="https://target.com" includeSubpath={false} />
|
||||
);
|
||||
expect(screen.getByText(/not seeing the routing you wanted/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,7 @@ interface ModalConfig {
|
|||
additionalFields?: Array<{
|
||||
name: string;
|
||||
label: string | React.ReactNode;
|
||||
type: "input" | "select" | "numerical";
|
||||
type: "input" | "select" | "numerical" | "multi_select";
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
rules?: any[];
|
||||
step?: number;
|
||||
|
|
@ -65,6 +65,7 @@ const MemberModal = <T extends BaseMember>({
|
|||
max_budget_in_team: (initialData as any).max_budget_in_team || null,
|
||||
tpm_limit: (initialData as any).tpm_limit || null,
|
||||
rpm_limit: (initialData as any).rpm_limit || null,
|
||||
extra_permissions: (initialData as any).extra_permissions || [],
|
||||
};
|
||||
console.log("Setting form values:", formValues);
|
||||
form.setFieldsValue(formValues);
|
||||
|
|
@ -115,7 +116,7 @@ const MemberModal = <T extends BaseMember>({
|
|||
const renderField = (field: {
|
||||
name: string;
|
||||
label: string | React.ReactNode;
|
||||
type: "input" | "select" | "numerical";
|
||||
type: "input" | "select" | "numerical" | "multi_select";
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
rules?: any[];
|
||||
step?: number;
|
||||
|
|
@ -144,6 +145,17 @@ const MemberModal = <T extends BaseMember>({
|
|||
))}
|
||||
</Select>
|
||||
);
|
||||
case "multi_select":
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={field.placeholder || "Select permissions"}
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
options={field.options}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import { AvailablePermission, Member, teamMemberUpdateCall } from "@/components/networking";
|
||||
import { CloudServerOutlined, SaveOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Drawer, Switch, Tag, Typography } from "antd";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
const { Text, Title: AntTitle } = Typography;
|
||||
|
||||
interface MemberPermissionsDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
member: Member | null;
|
||||
availablePermissions: AvailablePermission[];
|
||||
accessToken: string | null;
|
||||
teamId: string;
|
||||
onUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MemberPermissionsDrawer: React.FC<MemberPermissionsDrawerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
member,
|
||||
availablePermissions,
|
||||
accessToken,
|
||||
teamId,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Sync state when member changes or drawer opens — only seed with known permissions
|
||||
// so that unknown permissions are preserved separately on save without duplication
|
||||
useEffect(() => {
|
||||
if (open && member) {
|
||||
const knownValues = new Set(availablePermissions.map((p) => p.value));
|
||||
setSelected(new Set((member.extra_permissions || []).filter((p) => knownValues.has(p))));
|
||||
}
|
||||
}, [open, member, availablePermissions]);
|
||||
|
||||
const initial = useMemo(
|
||||
() => {
|
||||
const knownValues = new Set(availablePermissions.map((p) => p.value));
|
||||
return new Set((member?.extra_permissions || []).filter((p) => knownValues.has(p)));
|
||||
},
|
||||
[member, availablePermissions],
|
||||
);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (selected.size !== initial.size) return true;
|
||||
for (const p of selected) {
|
||||
if (!initial.has(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [selected, initial]);
|
||||
|
||||
const allSelected = availablePermissions.length > 0 && availablePermissions.every((p) => selected.has(p.value));
|
||||
|
||||
const handleToggle = (value: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(value);
|
||||
} else {
|
||||
next.delete(value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelected(new Set(availablePermissions.map((p) => p.value)));
|
||||
} else {
|
||||
setSelected(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessToken || !member) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Preserve unknown permissions not present in the available set
|
||||
const availableValues = new Set(availablePermissions.map((p) => p.value));
|
||||
const existingUnknown = (member.extra_permissions || []).filter(
|
||||
(p) => !availableValues.has(p),
|
||||
);
|
||||
const updatedMember: Member = {
|
||||
...member,
|
||||
extra_permissions: [...existingUnknown, ...Array.from(selected)],
|
||||
};
|
||||
await teamMemberUpdateCall(accessToken, teamId, updatedMember);
|
||||
NotificationsManager.success("Permissions updated successfully");
|
||||
onClose();
|
||||
try {
|
||||
await onUpdate();
|
||||
} catch (refreshError) {
|
||||
console.error("Failed to refresh team data after permission update:", refreshError);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errMsg = error?.message || "Failed to update permissions";
|
||||
NotificationsManager.fromBackend(errMsg);
|
||||
console.error("Error updating permissions:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const memberDisplay = member?.user_email || member?.user_id || "Unknown";
|
||||
const isAdmin = member?.role?.toLowerCase() === "admin";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-blue-50 text-blue-600 rounded-full shrink-0">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<Text strong className="text-base leading-tight truncate">
|
||||
{memberDisplay}
|
||||
</Text>
|
||||
<Text type="secondary" className="text-xs truncate">
|
||||
{member?.user_id || ""}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={isAdmin ? "gold" : "default"} className="ml-auto shrink-0">
|
||||
{member?.role || "user"}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
closable={!saving}
|
||||
maskClosable={!saving}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{isAdmin ? (
|
||||
<Alert
|
||||
message="Team admins have all permissions by default. Extra permissions only apply to non-admin members."
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message="Permissions allow this member to manage MCP servers for this team."
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MCP Server Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CloudServerOutlined className="text-lg text-gray-600" />
|
||||
<AntTitle level={5} className="!m-0">
|
||||
MCP Server Permissions
|
||||
</AntTitle>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200 mb-4 flex items-center justify-between">
|
||||
<Text strong>{allSelected ? "Deselect All" : "Select All"}</Text>
|
||||
<Switch
|
||||
checked={allSelected}
|
||||
onChange={handleToggleAll}
|
||||
disabled={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{availablePermissions.map((perm) => (
|
||||
<div
|
||||
key={perm.value}
|
||||
className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text>{perm.label}</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded w-fit border border-gray-200"
|
||||
>
|
||||
{perm.value}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={selected.has(perm.value)}
|
||||
onChange={(checked) => handleToggle(perm.value, checked)}
|
||||
disabled={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availablePermissions.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
No permissions available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberPermissionsDrawer;
|
||||
|
|
@ -2,6 +2,8 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
import {
|
||||
AvailablePermission,
|
||||
fetchAvailableTeamMemberPermissions,
|
||||
getGuardrailsList,
|
||||
getPoliciesList,
|
||||
getPolicyInfoWithGuardrails,
|
||||
|
|
@ -49,6 +51,7 @@ import {
|
|||
TEAM_INFO_TAB_KEYS,
|
||||
TEAM_INFO_TAB_LABELS,
|
||||
} from "./tabVisibilityUtils";
|
||||
import MemberPermissionsDrawer from "./MemberPermissionsDrawer";
|
||||
import TeamMembersComponent from "./TeamMemberTab";
|
||||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
|
||||
|
|
@ -184,6 +187,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isTeamSaving, setIsTeamSaving] = useState(false);
|
||||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const [availablePermissions, setAvailablePermissions] = useState<AvailablePermission[]>([]);
|
||||
const [permissionsDrawerMember, setPermissionsDrawerMember] = useState<Member | null>(null);
|
||||
const { userRole, userId } = useAuthorized();
|
||||
const { data: userOrganizations = [] } = useOrganizations();
|
||||
|
||||
|
|
@ -220,6 +225,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
fetchTeamInfo();
|
||||
}, [teamId, accessToken]);
|
||||
|
||||
// Fetch available permissions for team member editing (only for users who can edit the team)
|
||||
useEffect(() => {
|
||||
if (!canEditTeam) return;
|
||||
const fetchPermissions = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const permissions = await fetchAvailableTeamMemberPermissions(accessToken);
|
||||
setAvailablePermissions(permissions);
|
||||
} catch (error) {
|
||||
console.error("Error fetching available permissions:", error);
|
||||
}
|
||||
};
|
||||
fetchPermissions();
|
||||
}, [accessToken, canEditTeam]);
|
||||
|
||||
// Fetch organization data when team has organization_id
|
||||
useEffect(() => {
|
||||
const fetchOrganization = async () => {
|
||||
|
|
@ -366,6 +386,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
max_budget_in_team: values.max_budget_in_team,
|
||||
tpm_limit: values.tpm_limit,
|
||||
rpm_limit: values.rpm_limit,
|
||||
extra_permissions: values.extra_permissions,
|
||||
};
|
||||
MessageManager.destroy(); // Remove all existing toasts
|
||||
|
||||
|
|
@ -762,6 +783,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
setSelectedEditMember={setSelectedEditMember}
|
||||
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
|
||||
onPermissions={(member) => setPermissionsDrawerMember(member)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -1335,6 +1357,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<MemberPermissionsDrawer
|
||||
open={!!permissionsDrawerMember}
|
||||
onClose={() => setPermissionsDrawerMember(null)}
|
||||
member={permissionsDrawerMember}
|
||||
availablePermissions={availablePermissions}
|
||||
accessToken={accessToken}
|
||||
teamId={teamId}
|
||||
onUpdate={async () => {
|
||||
if (!accessToken) return;
|
||||
const updatedTeamData = await teamInfoCall(accessToken, teamId);
|
||||
setTeamData(updatedTeamData);
|
||||
onUpdate(updatedTeamData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface TeamMemberTabProps {
|
|||
setSelectedEditMember: (member: Member) => void;
|
||||
setIsEditMemberModalVisible: (visible: boolean) => void;
|
||||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
onPermissions?: (member: Member) => void;
|
||||
}
|
||||
|
||||
export default function TeamMemberTab({
|
||||
|
|
@ -25,6 +26,7 @@ export default function TeamMemberTab({
|
|||
setSelectedEditMember,
|
||||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
onPermissions,
|
||||
}: TeamMemberTabProps) {
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
|
@ -144,6 +146,7 @@ export default function TeamMemberTab({
|
|||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
onPermissions={onPermissions}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { Alert, Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
|
|
@ -25,6 +25,7 @@ import { fetchTeamModels } from "../organisms/create_key_button";
|
|||
import NumericalInput from "../shared/numerical_input";
|
||||
import { Tag } from "../tag_management/types";
|
||||
import EditLoggingSettings from "../team/EditLoggingSettings";
|
||||
import SearchToolSelector from "../SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface KeyEditViewProps {
|
||||
|
|
@ -186,6 +187,7 @@ export function KeyEditView({
|
|||
agents: keyData.object_permission?.agents || [],
|
||||
accessGroups: keyData.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
search_tools: keyData.object_permission?.search_tools || [],
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
|
||||
|
|
@ -214,6 +216,7 @@ export function KeyEditView({
|
|||
accessGroups: keyData.object_permission?.mcp_access_groups || [],
|
||||
},
|
||||
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
|
||||
search_tools: keyData.object_permission?.search_tools || [],
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
|
||||
|
|
@ -614,6 +617,26 @@ export function KeyEditView({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New keys have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label="Search Tools"
|
||||
name="search_tools"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("search_tools", values)}
|
||||
value={form.getFieldValue("search_tools")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
allowedSearchToolIds={team ? (team.object_permission?.search_tools ?? []) : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,15 @@ export default function KeyInfoView({
|
|||
delete formValues.agents_and_groups;
|
||||
}
|
||||
|
||||
// Handle search tool permissions
|
||||
if (formValues.search_tools !== undefined) {
|
||||
formValues.object_permission = {
|
||||
...formValues.object_permission,
|
||||
search_tools: formValues.search_tools || [],
|
||||
};
|
||||
delete formValues.search_tools;
|
||||
}
|
||||
|
||||
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
|
||||
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
|
||||
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AuditLogDrawer } from "./AuditLogDrawer";
|
||||
import { AuditLogEntry } from "../columns";
|
||||
|
||||
vi.mock("../../common_components/DefaultProxyAdminTag", () => ({
|
||||
default: ({ userId }: { userId: string }) => <span data-testid="proxy-admin-tag">{userId}</span>,
|
||||
}));
|
||||
|
||||
const mockLog: AuditLogEntry = {
|
||||
id: "log-1",
|
||||
updated_at: "2026-03-15T10:30:00Z",
|
||||
changed_by: "admin-user",
|
||||
changed_by_api_key: "sk-abc123hash",
|
||||
action: "created",
|
||||
table_name: "LiteLLM_TeamTable",
|
||||
object_id: "team-456",
|
||||
before_value: {},
|
||||
updated_values: { team_alias: "My Team", max_budget: 100 },
|
||||
};
|
||||
|
||||
describe("AuditLogDrawer", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
log: mockLog,
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(screen.getByText("created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when log is null", () => {
|
||||
const { container } = render(<AuditLogDrawer open={true} onClose={vi.fn()} log={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should display the friendly table name", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(screen.getByText("Teams")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the object ID", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(screen.getByText("team-456")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the changed_by user", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(screen.getByText("admin-user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the API key hash", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(screen.getByText("sk-abc123hash")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a close button", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogDrawer {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display raw table name when no friendly mapping exists", () => {
|
||||
const customLog = { ...mockLog, table_name: "CustomTable" };
|
||||
render(<AuditLogDrawer {...defaultProps} log={customLog} />);
|
||||
expect(screen.getByText("CustomTable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show diff sections for updated action with changed fields", () => {
|
||||
const updatedLog: AuditLogEntry = {
|
||||
...mockLog,
|
||||
action: "updated",
|
||||
before_value: { team_alias: "Old Name", max_budget: 50 },
|
||||
updated_values: { team_alias: "New Name", max_budget: 100 },
|
||||
};
|
||||
render(<AuditLogDrawer {...defaultProps} log={updatedLog} />);
|
||||
expect(screen.getByText("Before")).toBeInTheDocument();
|
||||
expect(screen.getByText("After")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -65,9 +65,10 @@ export function DataTable<TData, TValue>({
|
|||
const isSorted = header.column.getIsSorted();
|
||||
|
||||
return (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${canSort ? 'cursor-pointer select-none hover:bg-gray-50' : ''}`}
|
||||
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
|
|
@ -103,7 +104,7 @@ export function DataTable<TData, TValue>({
|
|||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap" style={{ width: cell.column.getSize() !== 150 ? cell.column.getSize() : undefined }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue