[Feature] UI - Search Tools: Add permissions UI, modernize page, infinite team dropdown

Add search_tools permission support across the UI (key create/edit, team create,
object permissions view) with breaking-change alerts for the new least-privilege
default. Modernize the Search Tools page with AntD Tabs, a Test playground tab,
and ProviderLogo integration. Migrate TeamDropdown to self-fetching infinite
scroll pattern using useInfiniteTeams hook. Scope search tools visibility for
internal users based on their team memberships.

Backend: Add search_tools field to Prisma schema (all copies), Pydantic models
(ObjectPermissionBase + ObjectPermissionTable), and allowed routes for virtual
keys. Add permission filtering to /search_tools/list endpoint. Include
object_permission in team list v2 queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-21 17:23:49 -07:00
parent 75bd742d18
commit efa5a3fc69
29 changed files with 870 additions and 336 deletions

View file

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

View file

@ -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:
@ -3590,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(
@ -3668,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)
@ -3678,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(
@ -3689,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
@ -3701,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}
)
)

View file

@ -8,7 +8,7 @@ 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.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 +46,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.
@ -161,6 +163,19 @@ async def list_search_tools():
)
)
# Filter based on caller's key/team permissions
from litellm.proxy.search_endpoints.endpoints import (
_get_allowed_search_tool_names,
)
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}")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,18 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
],
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", () => ({

View file

@ -556,10 +556,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
>
<TeamDropdown
teams={teams ?? []}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value)}
loading={isLoadingTeams}
/>
</Form.Item>
{!isAdmin && !selectedTeamId ? (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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