Merge pull request #26691 from BerriAI/litellm_team_search_credentials_metadata
feat(proxy): add team-level search provider credentials
This commit is contained in:
commit
6588564a88
@ -0,0 +1,2 @@
|
||||
-- Search tool allowlists live on LiteLLM_ObjectPermissionTable (with agents, MCP, vector stores).
|
||||
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable {
|
||||
models String[] @default([])
|
||||
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
|
||||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
||||
@ -904,6 +904,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
||||
agents: Optional[List[str]] = None
|
||||
agent_access_groups: Optional[List[str]] = None
|
||||
models: Optional[List[str]] = None
|
||||
search_tools: Optional[List[str]] = None
|
||||
|
||||
|
||||
class BudgetLimitEntry(LiteLLMPydanticObjectBase):
|
||||
@ -1934,6 +1935,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
|
||||
agent_access_groups: Optional[List[str]] = []
|
||||
mcp_toolsets: Optional[List[str]] = None
|
||||
blocked_tools: Optional[List[str]] = []
|
||||
search_tools: Optional[List[str]] = []
|
||||
|
||||
|
||||
class LiteLLM_TeamTable(TeamBase):
|
||||
|
||||
@ -2963,6 +2963,116 @@ async def can_user_call_model(
|
||||
)
|
||||
|
||||
|
||||
def _search_tool_names_from_object_permission(
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionTable],
|
||||
) -> List[str]:
|
||||
"""Return allowlisted search tool names from object_permission (empty = unrestricted)."""
|
||||
if object_permission is None:
|
||||
return []
|
||||
raw = object_permission.search_tools
|
||||
if not raw:
|
||||
return []
|
||||
return list(raw)
|
||||
|
||||
|
||||
def _can_object_call_search_tool(
|
||||
search_tool_name: str,
|
||||
allowed_search_tools: List[str],
|
||||
object_type: Literal["key", "team", "project"],
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Check if an object (key/team/project) can access a specific search tool.
|
||||
|
||||
Similar to _can_object_call_model but for search tools.
|
||||
|
||||
Args:
|
||||
search_tool_name: The search tool being requested
|
||||
allowed_search_tools: List of allowed search tool names for this object
|
||||
object_type: Type of object for error messaging
|
||||
|
||||
Returns:
|
||||
True if access is allowed
|
||||
|
||||
Raises:
|
||||
ProxyException if access is denied
|
||||
"""
|
||||
# Empty list means all search tools are allowed
|
||||
if not allowed_search_tools:
|
||||
return True
|
||||
|
||||
# Check if the search tool is in the allowlist
|
||||
if search_tool_name in allowed_search_tools:
|
||||
return True
|
||||
|
||||
# Access denied
|
||||
raise ProxyException(
|
||||
message=f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. "
|
||||
f"Allowed search tools: {allowed_search_tools}",
|
||||
type=ProxyErrorTypes.key_model_access_denied,
|
||||
param="search_tool_name",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
async def can_key_call_search_tool(
|
||||
search_tool_name: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Check if a key can access a specific search tool.
|
||||
|
||||
Similar to can_key_call_model but for search tools.
|
||||
|
||||
Args:
|
||||
search_tool_name: The search tool being requested
|
||||
valid_token: The authenticated key
|
||||
|
||||
Returns:
|
||||
True if access is allowed
|
||||
|
||||
Raises:
|
||||
ProxyException if access is denied
|
||||
"""
|
||||
return _can_object_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
allowed_search_tools=_search_tool_names_from_object_permission(
|
||||
valid_token.object_permission
|
||||
),
|
||||
object_type="key",
|
||||
)
|
||||
|
||||
|
||||
async def can_team_call_search_tool(
|
||||
search_tool_name: str,
|
||||
team_object: Optional[LiteLLM_TeamTable],
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Check if a team can access a specific search tool.
|
||||
|
||||
Similar to can_team_access_model but for search tools.
|
||||
|
||||
Args:
|
||||
search_tool_name: The search tool being requested
|
||||
team_object: The team object
|
||||
|
||||
Returns:
|
||||
True if access is allowed
|
||||
|
||||
Raises:
|
||||
ProxyException if access is denied
|
||||
"""
|
||||
if team_object is None:
|
||||
return True
|
||||
|
||||
return _can_object_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
allowed_search_tools=_search_tool_names_from_object_permission(
|
||||
team_object.object_permission
|
||||
),
|
||||
object_type="team",
|
||||
)
|
||||
|
||||
|
||||
async def is_valid_fallback_model(
|
||||
model: str,
|
||||
llm_router: Optional[Router],
|
||||
|
||||
@ -65,6 +65,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
attach_object_permission_to_dict,
|
||||
handle_update_object_permission_common,
|
||||
validate_key_mcp_servers_against_team,
|
||||
validate_key_search_tools_against_team,
|
||||
)
|
||||
from litellm.proxy.management_helpers.team_member_permission_checks import (
|
||||
TeamMemberPermissionChecks,
|
||||
@ -768,6 +769,10 @@ async def _common_key_generation_helper( # noqa: PLR0915
|
||||
object_permission=data_json.get("object_permission"),
|
||||
team_obj=team_table,
|
||||
)
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission=data_json.get("object_permission"),
|
||||
team_obj=team_table,
|
||||
)
|
||||
|
||||
data_json = await _set_object_permission(
|
||||
data_json=data_json,
|
||||
@ -2010,6 +2015,10 @@ async def _validate_mcp_servers_for_key_update(
|
||||
object_permission=object_permission_dict,
|
||||
team_obj=effective_team_obj,
|
||||
)
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission=object_permission_dict,
|
||||
team_obj=effective_team_obj,
|
||||
)
|
||||
|
||||
|
||||
async def _validate_update_key_data(
|
||||
|
||||
@ -2049,8 +2049,11 @@ async def _process_team_members(
|
||||
|
||||
# Resolve allowed_models: explicit request value, or fall back to team's default_team_member_models
|
||||
member_allowed_models = data.allowed_models
|
||||
if member_allowed_models is None and complete_team_data.default_team_member_models:
|
||||
member_allowed_models = complete_team_data.default_team_member_models
|
||||
team_default_member_models = getattr(
|
||||
complete_team_data, "default_team_member_models", None
|
||||
)
|
||||
if member_allowed_models is None and team_default_member_models:
|
||||
member_allowed_models = team_default_member_models
|
||||
|
||||
if isinstance(data.member, Member):
|
||||
try:
|
||||
|
||||
@ -335,8 +335,9 @@ async def validate_key_mcp_servers_against_team(
|
||||
disallowed_servers = requested_servers - all_allowed_servers
|
||||
if disallowed_servers:
|
||||
if team_obj is not None:
|
||||
team_id = team_obj.team_id
|
||||
detail = (
|
||||
f"Key requests MCP servers not allowed by team '{team_obj.team_id}': "
|
||||
f"Key requests MCP servers not allowed by team '{team_id}': "
|
||||
f"{sorted(disallowed_servers)}. "
|
||||
f"Team allows: {sorted(team_allowed_servers)}. "
|
||||
f"Global (allow_all_keys) servers: {sorted(allow_all_keys_servers)}."
|
||||
@ -365,8 +366,9 @@ async def validate_key_mcp_servers_against_team(
|
||||
disallowed_groups = requested_access_groups - team_access_groups
|
||||
if disallowed_groups:
|
||||
if team_obj is not None:
|
||||
team_id = team_obj.team_id
|
||||
detail = (
|
||||
f"Key requests MCP access groups not allowed by team '{team_obj.team_id}': "
|
||||
f"Key requests MCP access groups not allowed by team '{team_id}': "
|
||||
f"{sorted(disallowed_groups)}. "
|
||||
f"Team allows: {sorted(team_access_groups)}."
|
||||
)
|
||||
@ -390,13 +392,60 @@ async def validate_key_mcp_servers_against_team(
|
||||
if team_mcp_toolsets:
|
||||
disallowed_toolsets = requested_toolsets - set(team_mcp_toolsets)
|
||||
if disallowed_toolsets:
|
||||
team_id = team_obj.team_id
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": (
|
||||
f"Key requests MCP toolsets not allowed by team '{team_obj.team_id}': "
|
||||
f"Key requests MCP toolsets not allowed by team '{team_id}': "
|
||||
f"{sorted(disallowed_toolsets)}. "
|
||||
f"Team allows: {sorted(team_mcp_toolsets)}."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[str]:
|
||||
"""Return search_tool_name values from a key's object_permission dict."""
|
||||
if not object_permission or not isinstance(object_permission, dict):
|
||||
return []
|
||||
raw = object_permission.get("search_tools")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(x) for x in raw if x]
|
||||
|
||||
|
||||
async def validate_key_search_tools_against_team(
|
||||
object_permission: Optional[dict],
|
||||
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
|
||||
) -> None:
|
||||
"""
|
||||
Validate key object_permission.search_tools is a subset of the team's allowlist.
|
||||
|
||||
Empty team allowlist means no restriction at team layer (skip).
|
||||
"""
|
||||
requested = _extract_requested_search_tools(object_permission)
|
||||
if not requested:
|
||||
return
|
||||
|
||||
team_tools: List[str] = []
|
||||
if team_obj is not None and team_obj.object_permission is not None:
|
||||
st = team_obj.object_permission.search_tools
|
||||
if st:
|
||||
team_tools = list(st)
|
||||
|
||||
if not team_tools:
|
||||
return
|
||||
|
||||
disallowed = set(requested) - set(team_tools)
|
||||
if disallowed:
|
||||
team_id = team_obj.team_id if team_obj is not None else "unknown"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": (
|
||||
f"Key requests search tools not allowed by team '{team_id}': "
|
||||
f"{sorted(disallowed)}. Team allows: {sorted(team_tools)}."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable {
|
||||
models String[] @default([])
|
||||
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
|
||||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
||||
@ -134,10 +134,48 @@ async def search(
|
||||
|
||||
if "search_tool_name" in data and data["search_tool_name"]:
|
||||
data["model"] = data["search_tool_name"]
|
||||
search_tool_name_value = data["search_tool_name"]
|
||||
|
||||
# Authorization check: verify key can access this search tool
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_key_call_search_tool,
|
||||
can_team_call_search_tool,
|
||||
get_team_object,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check key-level access
|
||||
await can_key_call_search_tool(
|
||||
search_tool_name=search_tool_name_value,
|
||||
valid_token=user_api_key_dict,
|
||||
)
|
||||
|
||||
# Check team-level access if key is associated with a team
|
||||
if user_api_key_dict.team_id:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
team_object = await get_team_object(
|
||||
team_id=user_api_key_dict.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await can_team_call_search_tool(
|
||||
search_tool_name=search_tool_name_value,
|
||||
team_object=team_object,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Search tool authorization failed for {search_tool_name_value}: {str(e)}"
|
||||
)
|
||||
raise
|
||||
|
||||
if llm_router is not None and hasattr(llm_router, "search_tools"):
|
||||
search_tool_name_value = data["search_tool_name"]
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Search endpoint - Looking for search_tool_name: {search_tool_name_value}. "
|
||||
f"Available search tools in router: {[tool.get('search_tool_name') for tool in llm_router.search_tools]}. "
|
||||
@ -163,6 +201,16 @@ async def search(
|
||||
data["metadata"] = {}
|
||||
data["metadata"]["model_group"] = search_tool_name_value
|
||||
|
||||
# Ensure team context is available to search router credential resolution.
|
||||
# add_litellm_data_to_request() also injects these values, but this keeps
|
||||
# search endpoint behavior explicit and resilient for direct router paths.
|
||||
if "metadata" not in data or not isinstance(data.get("metadata"), dict):
|
||||
data["metadata"] = {}
|
||||
if getattr(user_api_key_dict, "team_metadata", None) is not None:
|
||||
data["metadata"]["user_api_key_team_metadata"] = user_api_key_dict.team_metadata
|
||||
if getattr(user_api_key_dict, "team_id", None) is not None:
|
||||
data["metadata"]["user_api_key_team_id"] = user_api_key_dict.team_id
|
||||
|
||||
# Process request using ProxyBaseLLMRequestProcessing
|
||||
processor = ProxyBaseLLMRequestProcessing(data=data)
|
||||
try:
|
||||
|
||||
@ -8,7 +8,7 @@ import asyncio
|
||||
import random
|
||||
import traceback
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
|
||||
@ -20,6 +20,28 @@ class SearchAPIRouter:
|
||||
Provides methods for search tool selection, load balancing, and fallback handling.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_search_provider_credentials(
|
||||
*,
|
||||
tool_litellm_params: Dict[str, Any],
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Resolve search provider credentials from tool configuration ONLY.
|
||||
|
||||
Credentials are stored only in search_tool.litellm_params, never in team/key metadata.
|
||||
This ensures secrets are not exposed in team/key API responses.
|
||||
|
||||
Args:
|
||||
tool_litellm_params: Search tool litellm_params with credentials
|
||||
|
||||
Returns:
|
||||
Tuple of (api_key, api_base) from tool configuration
|
||||
"""
|
||||
resolved_api_key: Optional[str] = tool_litellm_params.get("api_key")
|
||||
resolved_api_base: Optional[str] = tool_litellm_params.get("api_base")
|
||||
|
||||
return resolved_api_key, resolved_api_base
|
||||
|
||||
@staticmethod
|
||||
async def update_router_search_tools(router_instance: Any, search_tools: list):
|
||||
"""
|
||||
@ -198,14 +220,15 @@ class SearchAPIRouter:
|
||||
# Extract search provider and other params from litellm_params
|
||||
litellm_params = selected_tool.get("litellm_params", {})
|
||||
search_provider = litellm_params.get("search_provider")
|
||||
api_key = litellm_params.get("api_key")
|
||||
api_base = litellm_params.get("api_base")
|
||||
|
||||
if not search_provider:
|
||||
raise ValueError(
|
||||
f"search_provider not found in litellm_params for search tool '{search_tool_name}'"
|
||||
)
|
||||
|
||||
api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials(
|
||||
tool_litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
verbose_router_logger.debug(
|
||||
f"Selected search tool with provider: {search_provider}"
|
||||
)
|
||||
|
||||
@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable {
|
||||
models String[] @default([])
|
||||
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
|
||||
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
|
||||
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
|
||||
teams LiteLLM_TeamTable[]
|
||||
projects LiteLLM_ProjectTable[]
|
||||
verification_tokens LiteLLM_VerificationToken[]
|
||||
|
||||
@ -16,6 +16,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
_resolve_team_allowed_mcp_servers,
|
||||
_set_object_permission,
|
||||
validate_key_mcp_servers_against_team,
|
||||
validate_key_search_tools_against_team,
|
||||
)
|
||||
|
||||
|
||||
@ -453,3 +454,52 @@ async def test_resolve_team_allowed_mcp_servers_dict_tool_permissions(
|
||||
|
||||
result = await _resolve_team_allowed_mcp_servers(mock_perm)
|
||||
assert result == {"server-a"}
|
||||
|
||||
|
||||
# ---- Tests for validate_key_search_tools_against_team ----
|
||||
|
||||
|
||||
def _make_team_obj_search(team_id="team-1", search_tools=None):
|
||||
mock_team = MagicMock()
|
||||
mock_team.team_id = team_id
|
||||
if search_tools is not None:
|
||||
mock_team.object_permission = MagicMock(spec=LiteLLM_ObjectPermissionTable)
|
||||
mock_team.object_permission.search_tools = search_tools
|
||||
else:
|
||||
mock_team.object_permission = None
|
||||
return mock_team
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_search_tools_no_key_request():
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission=None,
|
||||
team_obj=_make_team_obj_search(search_tools=["t1"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_search_tools_team_unrestricted():
|
||||
"""Empty team search allowlist means unrestricted — key subset check skipped."""
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission={"search_tools": ["any-tool"]},
|
||||
team_obj=_make_team_obj_search(search_tools=[]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_search_tools_subset_ok():
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission={"search_tools": ["t1"]},
|
||||
team_obj=_make_team_obj_search(search_tools=["t1", "t2"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_search_tools_raises_when_not_subset():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission={"search_tools": ["bad"]},
|
||||
team_obj=_make_team_obj_search(search_tools=["t1"]),
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@ -7,6 +7,11 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
// Required with output: "export" — default image optimizer runs only in server mode.
|
||||
// See https://nextjs.org/docs/messages/export-image-api
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
basePath: "",
|
||||
assetPrefix: "/litellm-asset-prefix",
|
||||
turbopack: {
|
||||
|
||||
@ -15,10 +15,18 @@ import ModelAliasManager from "@/components/common_components/ModelAliasManager"
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, Organization, Team, teamCreateCall } from "@/components/networking";
|
||||
import {
|
||||
fetchMCPAccessGroups,
|
||||
getGuardrailsList,
|
||||
getPoliciesList,
|
||||
Organization,
|
||||
Team,
|
||||
teamCreateCall,
|
||||
} from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import SearchToolSelector from "@/components/SearchTools/SearchToolSelector";
|
||||
|
||||
interface ModelAliases {
|
||||
[key: string]: string;
|
||||
@ -212,15 +220,27 @@ const CreateTeamModal = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
|
||||
// Transform integrations into object_permission (vector stores, MCP, agents, search tools)
|
||||
const hasAgents =
|
||||
formValues.allowed_agents_and_groups &&
|
||||
((formValues.allowed_agents_and_groups.agents?.length ?? 0) > 0 ||
|
||||
(formValues.allowed_agents_and_groups.accessGroups?.length ?? 0) > 0);
|
||||
const hasSearchTools =
|
||||
Array.isArray(formValues.object_permission_search_tools) &&
|
||||
formValues.object_permission_search_tools.length > 0;
|
||||
|
||||
if (
|
||||
(formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) ||
|
||||
(formValues.allowed_mcp_servers_and_groups &&
|
||||
(formValues.allowed_mcp_servers_and_groups.servers?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.toolPermissions))
|
||||
formValues.allowed_mcp_servers_and_groups.toolPermissions)) ||
|
||||
hasAgents ||
|
||||
hasSearchTools
|
||||
) {
|
||||
formValues.object_permission = {};
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) {
|
||||
formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids;
|
||||
delete formValues.allowed_vector_store_ids;
|
||||
@ -238,9 +258,6 @@ const CreateTeamModal = ({
|
||||
|
||||
// Add tool permissions separately
|
||||
if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions;
|
||||
delete formValues.mcp_tool_permissions;
|
||||
}
|
||||
@ -248,9 +265,6 @@ const CreateTeamModal = ({
|
||||
// Handle agent permissions
|
||||
if (formValues.allowed_agents_and_groups) {
|
||||
const { agents, accessGroups } = formValues.allowed_agents_and_groups;
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
if (agents && agents.length > 0) {
|
||||
formValues.object_permission.agents = agents;
|
||||
}
|
||||
@ -259,6 +273,11 @@ const CreateTeamModal = ({
|
||||
}
|
||||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
if (hasSearchTools) {
|
||||
formValues.object_permission.search_tools = formValues.object_permission_search_tools;
|
||||
delete formValues.object_permission_search_tools;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform allowed_mcp_access_groups into object_permission
|
||||
@ -729,6 +748,34 @@ const CreateTeamModal = ({
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this team can access. Leave empty to allow all search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="object_permission_search_tools"
|
||||
className="mt-4"
|
||||
help="Restrict which configured search tools keys on this team may call."
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
|
||||
value={form.getFieldValue("object_permission_search_tools")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (optional, empty = all allowed)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
|
||||
@ -57,9 +57,16 @@ import type { KeyResponse, Team } from "./key_team_helpers/key_list";
|
||||
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
|
||||
import {
|
||||
Organization,
|
||||
fetchMCPAccessGroups,
|
||||
getGuardrailsList,
|
||||
getPoliciesList,
|
||||
teamDeleteCall,
|
||||
} from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "./SearchTools/SearchToolSelector";
|
||||
|
||||
interface TeamProps {
|
||||
teams: Team[] | null;
|
||||
@ -505,7 +512,10 @@ const Teams: React.FC<TeamProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
|
||||
const hasSearchTools =
|
||||
Array.isArray(formValues.object_permission_search_tools) &&
|
||||
formValues.object_permission_search_tools.length > 0;
|
||||
|
||||
if (
|
||||
(formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) ||
|
||||
(formValues.allowed_mcp_servers_and_groups &&
|
||||
@ -513,7 +523,9 @@ const Teams: React.FC<TeamProps> = ({
|
||||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.toolPermissions))
|
||||
) {
|
||||
formValues.object_permission = {};
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) {
|
||||
formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids;
|
||||
delete formValues.allowed_vector_store_ids;
|
||||
@ -529,11 +541,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
delete formValues.allowed_mcp_servers_and_groups;
|
||||
}
|
||||
|
||||
// Add tool permissions separately
|
||||
if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions;
|
||||
delete formValues.mcp_tool_permissions;
|
||||
}
|
||||
@ -563,6 +571,14 @@ const Teams: React.FC<TeamProps> = ({
|
||||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
if (hasSearchTools) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.search_tools = formValues.object_permission_search_tools;
|
||||
delete formValues.object_permission_search_tools;
|
||||
}
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
@ -1516,6 +1532,34 @@ const Teams: React.FC<TeamProps> = ({
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this team can access. Leave empty to allow all search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="object_permission_search_tools"
|
||||
className="mt-4"
|
||||
help="Restrict which configured search tools keys on this team may call."
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
|
||||
value={form.getFieldValue("object_permission_search_tools")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (optional, empty = all allowed)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { fetchSearchTools } from "../networking";
|
||||
|
||||
export interface SearchToolSelectorProps {
|
||||
onChange: (selected: string[]) => void;
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select search tools (optional)",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchSearchTools(accessToken);
|
||||
const tools = Array.isArray(data?.search_tools)
|
||||
? data.search_tools
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: [];
|
||||
setOptions(
|
||||
tools
|
||||
.map((tool: { search_tool_name?: string }) => tool?.search_tool_name)
|
||||
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0)
|
||||
.map((name: string) => ({ label: name, value: name })),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load search tools:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
options={options}
|
||||
style={{ width: "100%" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolSelector;
|
||||
@ -141,6 +141,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
"Error Message",
|
||||
"Key Hash",
|
||||
"Model",
|
||||
"Public model / search tool",
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@ -13,6 +13,7 @@ interface ObjectPermission {
|
||||
vector_stores: string[];
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
search_tools?: string[];
|
||||
}
|
||||
|
||||
interface ObjectPermissionsViewProps {
|
||||
@ -35,6 +36,7 @@ export function ObjectPermissionsView({
|
||||
const mcpToolsets = objectPermission?.mcp_toolsets || [];
|
||||
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"}>
|
||||
@ -51,6 +53,16 @@ export function ObjectPermissionsView({
|
||||
agentAccessGroups={agentAccessGroups}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<div className="rounded-md border border-gray-100 p-4">
|
||||
<Text className="text-sm font-medium text-gray-800">Search tools</Text>
|
||||
{searchTools.length === 0 ? (
|
||||
<Text className="mt-1 block text-xs text-gray-500">
|
||||
No restriction — all configured search tools are allowed for this team.
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="mt-1 block text-xs text-gray-700">{searchTools.join(", ")}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ import { fetchMCPAccessGroups } from "../networking";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "../SearchTools/SearchToolSelector";
|
||||
import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import MemberModal from "./EditMembership";
|
||||
@ -118,6 +119,7 @@ export interface TeamData {
|
||||
vector_stores: string[];
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
search_tools?: string[];
|
||||
};
|
||||
team_member_budget_table: {
|
||||
max_budget: number;
|
||||
@ -593,6 +595,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
updateData.object_permission.vector_stores = values.vector_stores;
|
||||
}
|
||||
|
||||
if (Array.isArray(values.object_permission_search_tools)) {
|
||||
updateData.object_permission.search_tools = values.object_permission_search_tools;
|
||||
}
|
||||
|
||||
// Pass access_group_ids to the update request
|
||||
if (values.access_group_ids !== undefined) {
|
||||
updateData.access_group_ids = values.access_group_ids;
|
||||
@ -926,6 +932,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
models: info.models,
|
||||
tpm_limit: info.tpm_limit,
|
||||
rpm_limit: info.rpm_limit,
|
||||
object_permission_search_tools: info.object_permission?.search_tools || [],
|
||||
modelLimits: Array.from(
|
||||
new Set([
|
||||
...Object.keys(info.metadata?.model_tpm_limit ?? {}),
|
||||
@ -1379,6 +1386,26 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label="Allowed Search Tools"
|
||||
name="object_permission_search_tools"
|
||||
tooltip="Select which search tools this team can access. Leave empty to allow all search tools."
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
|
||||
value={form.getFieldValue("object_permission_search_tools")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (optional, empty = all allowed)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Form.Item label="Organization" name="organization_id">
|
||||
<Select
|
||||
allowClear
|
||||
@ -1614,6 +1641,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -7,15 +7,25 @@ import type { Row } from "@tanstack/react-table";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
|
||||
const mockHandleFilterResetFromHook = vi.fn();
|
||||
vi.mock("./log_filter_logic", () => ({
|
||||
useLogFilterLogic: vi.fn(() => ({
|
||||
filters: {},
|
||||
filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 },
|
||||
allTeams: [],
|
||||
handleFilterChange: vi.fn(),
|
||||
handleFilterReset: mockHandleFilterResetFromHook,
|
||||
})),
|
||||
}));
|
||||
vi.mock("./log_filter_logic", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./log_filter_logic")>();
|
||||
return {
|
||||
...actual,
|
||||
useLogFilterLogic: vi.fn(() => ({
|
||||
filters: {},
|
||||
filteredLogs: {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 1,
|
||||
},
|
||||
allTeams: [],
|
||||
handleFilterChange: vi.fn(),
|
||||
handleFilterReset: mockHandleFilterResetFromHook,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../networking")>();
|
||||
|
||||
@ -24,7 +24,7 @@ import { ConfigInfoMessage } from "./ConfigInfoMessage";
|
||||
import { AGENT_CALL_TYPES, ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants";
|
||||
import { CostBreakdownViewer } from "./CostBreakdownViewer";
|
||||
import { ErrorViewer } from "./ErrorViewer";
|
||||
import { useLogFilterLogic } from "./log_filter_logic";
|
||||
import { FILTER_KEYS, useLogFilterLogic } from "./log_filter_logic";
|
||||
import { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
import { getTimeRangeDisplay } from "./logs_utils";
|
||||
import { RequestResponsePanel } from "./RequestResponsePanel";
|
||||
@ -415,6 +415,11 @@ export default function SpendLogsTable({
|
||||
label: "Model",
|
||||
customComponent: PaginatedModelSelect,
|
||||
},
|
||||
{
|
||||
name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
|
||||
label: "Public model / search tool",
|
||||
isSearchable: false,
|
||||
},
|
||||
{
|
||||
name: "Key Alias",
|
||||
label: "Key Alias",
|
||||
|
||||
@ -112,6 +112,7 @@ describe("useLogFilterLogic", () => {
|
||||
expect(filters["Key Alias"]).toBe("");
|
||||
expect(filters["Error Code"]).toBe("");
|
||||
expect(filters["Error Message"]).toBe("");
|
||||
expect(filters["Public model / search tool"]).toBe("");
|
||||
});
|
||||
|
||||
it("should return all logs when no filters are applied", () => {
|
||||
@ -200,6 +201,51 @@ describe("useLogFilterLogic", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass model param and filter search-tool rows by spend log model column", async () => {
|
||||
const searchRows = [
|
||||
createLogEntry({
|
||||
request_id: "s1",
|
||||
call_type: "asearch",
|
||||
model: "tavily-marketing",
|
||||
model_id: "",
|
||||
team_id: "team-x",
|
||||
}),
|
||||
];
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(createPaginatedResponse(searchRows));
|
||||
const logs = createPaginatedResponse([
|
||||
...searchRows,
|
||||
createLogEntry({
|
||||
request_id: "c1",
|
||||
call_type: "chat",
|
||||
model: "gpt-4o",
|
||||
model_id: "mid-1",
|
||||
team_id: "team-x",
|
||||
}),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].model).toBe("tavily-marketing");
|
||||
expect(result.current.filteredLogs.data[0].call_type).toBe("asearch");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
expect(vi.mocked(uiSpendLogsCall)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
model: "tavily-marketing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter logs by api_key when Key Hash filter is set", async () => {
|
||||
const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" });
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
|
||||
@ -9,11 +9,14 @@ import { defaultPageSize } from "../constants";
|
||||
import { PaginatedResponse } from ".";
|
||||
import type { LogsSortField } from "./columns";
|
||||
|
||||
const FILTER_KEYS = {
|
||||
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
|
||||
export const FILTER_KEYS = {
|
||||
TEAM_ID: "Team ID",
|
||||
KEY_HASH: "Key Hash",
|
||||
REQUEST_ID: "Request ID",
|
||||
MODEL: "Model",
|
||||
/** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */
|
||||
PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool",
|
||||
USER_ID: "User ID",
|
||||
END_USER: "End User",
|
||||
STATUS: "Status",
|
||||
@ -58,6 +61,7 @@ export function useLogFilterLogic({
|
||||
[FILTER_KEYS.KEY_HASH]: "",
|
||||
[FILTER_KEYS.REQUEST_ID]: "",
|
||||
[FILTER_KEYS.MODEL]: "",
|
||||
[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "",
|
||||
[FILTER_KEYS.USER_ID]: "",
|
||||
[FILTER_KEYS.END_USER]: "",
|
||||
[FILTER_KEYS.STATUS]: "",
|
||||
@ -107,6 +111,7 @@ export function useLogFilterLogic({
|
||||
end_user: filters[FILTER_KEYS.END_USER] || undefined,
|
||||
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
|
||||
model_id: filters[FILTER_KEYS.MODEL] || undefined,
|
||||
model: filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined,
|
||||
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
|
||||
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
|
||||
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
|
||||
@ -155,7 +160,8 @@ export function useLogFilterLogic({
|
||||
filters[FILTER_KEYS.END_USER] ||
|
||||
filters[FILTER_KEYS.ERROR_CODE] ||
|
||||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
|
||||
filters[FILTER_KEYS.MODEL]
|
||||
filters[FILTER_KEYS.MODEL] ||
|
||||
filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]
|
||||
),
|
||||
[filters],
|
||||
);
|
||||
@ -218,6 +224,11 @@ export function useLogFilterLogic({
|
||||
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
|
||||
}
|
||||
|
||||
if (filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]) {
|
||||
const m = filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL];
|
||||
filteredData = filteredData.filter((log) => log.model === m);
|
||||
}
|
||||
|
||||
if (filters[FILTER_KEYS.KEY_HASH]) {
|
||||
filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user