mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
[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:
parent
75bd742d18
commit
efa5a3fc69
29 changed files with 870 additions and 336 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -210,9 +210,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Team" name="team_id">
|
||||
<Select placeholder="Select Team" style={{ width: "100%" }}>
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
</Select>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
|
|
@ -294,7 +292,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
name="team_id"
|
||||
help="If selected, user will be added as a 'user' role to the team."
|
||||
>
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
TextInput,
|
||||
} from "@tremor/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
|
|
@ -59,6 +60,7 @@ import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
|||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import SearchToolSelector from "./SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface TeamProps {
|
||||
|
|
@ -563,6 +565,14 @@ const Teams: React.FC<TeamProps> = ({
|
|||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
// Always send search_tools to ensure the permission record is created.
|
||||
// Empty array = no access (least privilege for new teams).
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
|
||||
delete formValues.allowed_search_tool_ids;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
|
|
@ -579,14 +589,12 @@ const Teams: React.FC<TeamProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const response: any = await teamCreateCall(accessToken, formValues);
|
||||
if (teams !== null) {
|
||||
setTeams([...teams, response]);
|
||||
} else {
|
||||
setTeams([response]);
|
||||
}
|
||||
console.log(`response for team create call: ${response}`);
|
||||
await teamCreateCall(accessToken, formValues);
|
||||
NotificationsManager.success("Team created");
|
||||
await fetchTeamsV2({
|
||||
page: currentPage,
|
||||
size: pageSize,
|
||||
});
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
|
|
@ -1516,6 +1524,40 @@ const Teams: React.FC<TeamProps> = ({
|
|||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New teams have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this team can access. New teams default to no access — explicitly grant access to specific search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_search_tool_ids"
|
||||
className="mt-4"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
|
||||
value={form.getFieldValue("allowed_search_tool_ids")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,14 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
|
||||
import Image from "next/image";
|
||||
import { Button, Form, Input, Modal, Select, Tooltip, Typography } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ProviderLogo } from "../molecules/models/ProviderLogo";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { createSearchTool, fetchAvailableSearchProviders } from "../networking";
|
||||
import SearchConnectionTest from "./SearchConnectionTest";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
// Search provider logos folder path (matches existing provider logo pattern)
|
||||
const searchProviderLogosFolder = "../ui/assets/logos/";
|
||||
|
||||
// Helper function to get logo path for a search provider
|
||||
const getSearchProviderLogo = (providerName: string): string => {
|
||||
return `${searchProviderLogosFolder}${providerName}.png`;
|
||||
};
|
||||
|
||||
// Component to display search provider logo and name
|
||||
interface SearchProviderLabelProps {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ providerName, displayName }) => (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Image
|
||||
src={getSearchProviderLogo(providerName)}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CreateSearchToolProps {
|
||||
userRole: string;
|
||||
accessToken: string | null;
|
||||
|
|
@ -85,7 +49,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
const handleCreate = async (formValues: Record<string, any>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Prepare the payload
|
||||
const payload = {
|
||||
search_tool_name: formValues.search_tool_name,
|
||||
litellm_params: {
|
||||
|
|
@ -102,8 +65,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
: undefined,
|
||||
};
|
||||
|
||||
console.log(`Creating search tool with payload:`, payload);
|
||||
|
||||
if (accessToken != null) {
|
||||
const response = await createSearchTool(accessToken, payload);
|
||||
|
||||
|
|
@ -128,13 +89,10 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
// Validate required fields for testing
|
||||
await form.validateFields(["search_provider", "api_key"]);
|
||||
|
||||
setIsTestingConnection(true);
|
||||
// Generate a new test ID (using timestamp for uniqueness)
|
||||
setConnectionTestId(`test-${Date.now()}`);
|
||||
// Show the modal with the fresh test
|
||||
setIsTestModalVisible(true);
|
||||
} catch (error) {
|
||||
NotificationsManager.error("Please fill in Search Provider and API Key before testing");
|
||||
|
|
@ -154,144 +112,112 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add New Search Tool</h2>
|
||||
title="Add New Search Tool"
|
||||
open={isModalVisible}
|
||||
width={600}
|
||||
onCancel={handleCancel}
|
||||
footer={
|
||||
<div className="flex justify-between items-center">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
|
||||
Need Help?
|
||||
</Typography.Link>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleTestConnection} loading={isTestingConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={isLoading}>
|
||||
Add Search Tool
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={800}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||
layout="vertical"
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Search Tool Name{" "}
|
||||
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_tool_name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a search tool name" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: "Name can only contain letters, numbers, hyphens, and underscores",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Search Tool Name
|
||||
<Tooltip title="A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_tool_name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a search tool name" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: "Name can only contain letters, numbers, hyphens, and underscores",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="e.g., perplexity-search, my-tavily-tool"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Input placeholder="e.g., perplexity-search, my-tavily-tool" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Search Provider
|
||||
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a search provider"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
loading={isLoadingProviders}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
optionLabelProp="label"
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Search Provider{" "}
|
||||
<Tooltip title="Select the search provider you want to use. Each provider has different capabilities and pricing.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="search_provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a search provider"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
optionLabelProp="label"
|
||||
notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
label={provider.ui_friendly_name}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
label={
|
||||
<SearchProviderLabel
|
||||
providerName={provider.provider_name}
|
||||
displayName={provider.ui_friendly_name}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SearchProviderLabel
|
||||
providerName={provider.provider_name}
|
||||
displayName={provider.ui_friendly_name}
|
||||
/>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<ProviderLogo provider={provider.provider_name} className="w-5 h-5" />
|
||||
<span>{provider.ui_friendly_name}</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
API Key
|
||||
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="api_key"
|
||||
rules={[{ required: false, message: "Please enter an API key" }]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter your API key"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
API Key{" "}
|
||||
<Tooltip title="The API key for authenticating with the search provider. This will be securely stored.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="api_key"
|
||||
>
|
||||
<Input.Password placeholder="Enter your API key" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Description (Optional)</span>}
|
||||
name="description"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="Brief description of this search tool's purpose"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues" target="_blank">
|
||||
Need Help?
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleTestConnection} loading={isTestingConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button loading={isLoading} type="submit">
|
||||
Add Search Tool
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<Form.Item
|
||||
label="Description (Optional)"
|
||||
name="description"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="Brief description of this search tool's purpose"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* Test Connection Results Modal */}
|
||||
<Modal
|
||||
|
|
@ -314,7 +240,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
]}
|
||||
width={700}
|
||||
>
|
||||
{/* Only render the SearchConnectionTest when modal is visible and we have a test ID */}
|
||||
{isTestModalVisible && accessToken && (
|
||||
<SearchConnectionTest
|
||||
key={connectionTestId}
|
||||
|
|
@ -333,4 +258,3 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
};
|
||||
|
||||
export default CreateSearchTool;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,31 +27,41 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({
|
|||
} | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Run test only once on mount — parent controls remounting via `key` prop
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const runTest = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await testSearchToolConnection(accessToken, litellmParams);
|
||||
if (cancelled) return;
|
||||
setTestResult(result);
|
||||
if (result.status === "success") {
|
||||
NotificationsManager.success("Connection test successful!");
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setTestResult({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error_type: "NetworkError",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (onTestComplete) {
|
||||
onTestComplete();
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
onTestComplete?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runTest();
|
||||
}, [accessToken, litellmParams, onTestComplete]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getCleanErrorMessage = (errorMsg: string) => {
|
||||
if (!errorMsg) return "Unknown error";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const searchToolColumns = (
|
|||
onEdit: (searchToolId: string) => void,
|
||||
onDelete: (searchToolId: string) => void,
|
||||
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
|
||||
isAdmin: boolean = true,
|
||||
): ColumnsType<SearchTool> => [
|
||||
{
|
||||
title: "Search Tool ID",
|
||||
|
|
@ -88,10 +89,10 @@ export const searchToolColumns = (
|
|||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
|
||||
disabled={isFromConfig || !isAdmin}
|
||||
disabledTooltipText={!isAdmin ? "Only admins can edit search tools" : "Config search tool cannot be edited on the dashboard. Please edit it from the config file."}
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
if (toolId && !isFromConfig && isAdmin) {
|
||||
onEdit(toolId);
|
||||
}
|
||||
}}
|
||||
|
|
@ -99,10 +100,10 @@ export const searchToolColumns = (
|
|||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
disabled={isFromConfig || !isAdmin}
|
||||
disabledTooltipText={!isAdmin ? "Only admins can delete search tools" : "Config search tool cannot be deleted on the dashboard. Please delete it from the config file."}
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
if (toolId && !isFromConfig && isAdmin) {
|
||||
onDelete(toolId);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { SearchTool } from "./types";
|
||||
import { fetchSearchTools } from "../networking";
|
||||
|
||||
interface SearchToolSelectorProps {
|
||||
onChange: (selectedSearchTools: string[]) => void;
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When set, only search tools whose IDs appear in this list are shown.
|
||||
* A list containing "*" means all tools are allowed (wildcard / legacy).
|
||||
* Undefined means no filtering (proxy admin without a team context).
|
||||
*/
|
||||
allowedSearchToolIds?: string[];
|
||||
}
|
||||
|
||||
const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select search tools",
|
||||
disabled = false,
|
||||
allowedSearchToolIds,
|
||||
}) => {
|
||||
const [searchTools, setSearchTools] = useState<SearchTool[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSearchTools = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchSearchTools(accessToken);
|
||||
if (response.search_tools) {
|
||||
setSearchTools(response.search_tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching search tools:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSearchTools();
|
||||
}, [accessToken]);
|
||||
|
||||
// Filter tools based on team permissions
|
||||
const filteredTools = useMemo(() => {
|
||||
if (allowedSearchToolIds === undefined) return searchTools;
|
||||
// Wildcard means all tools are allowed
|
||||
if (allowedSearchToolIds.length === 1 && allowedSearchToolIds[0] === "*") return searchTools;
|
||||
// Empty list means no tools are allowed
|
||||
if (allowedSearchToolIds.length === 0) return [];
|
||||
// Filter to only allowed IDs
|
||||
return searchTools.filter(
|
||||
(tool) => allowedSearchToolIds.includes(tool.search_tool_id || tool.search_tool_name),
|
||||
);
|
||||
}, [searchTools, allowedSearchToolIds]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
options={filteredTools.map((tool) => ({
|
||||
label: `${tool.search_tool_name}${tool.search_tool_id ? ` (${tool.search_tool_id})` : ""}`,
|
||||
value: tool.search_tool_id || tool.search_tool_name,
|
||||
title: tool.search_tool_info?.description || tool.search_tool_name,
|
||||
}))}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolSelector;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useState } from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { SearchTool, AvailableSearchProvider } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SearchToolTestPlaygroundProps {
|
||||
searchTools: SearchTool[];
|
||||
availableProviders: AvailableSearchProvider[];
|
||||
isLoading: boolean;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const SearchToolTestPlayground: React.FC<SearchToolTestPlaygroundProps> = ({
|
||||
searchTools,
|
||||
availableProviders,
|
||||
isLoading,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [selectedToolName, setSelectedToolName] = useState<string | null>(null);
|
||||
|
||||
const getProviderDisplayName = (providerName: string) => {
|
||||
const provider = availableProviders.find((p) => p.provider_name === providerName);
|
||||
return provider?.ui_friendly_name || providerName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm text-gray-600 mb-3 block">
|
||||
Select a search tool to test with live queries.
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="Select a search tool to test"
|
||||
className="w-full"
|
||||
size="large"
|
||||
value={selectedToolName}
|
||||
onChange={setSelectedToolName}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={searchTools.map((tool) => ({
|
||||
label: `${tool.search_tool_name} (${getProviderDisplayName(tool.litellm_params.search_provider)})`,
|
||||
value: tool.search_tool_name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedToolName ? (
|
||||
<SearchToolTester
|
||||
searchToolName={selectedToolName}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<SearchOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
|
||||
<Text className="text-lg font-medium text-gray-600 mb-2">
|
||||
Select a Search Tool to Test
|
||||
</Text>
|
||||
<Text className="text-center text-gray-500 max-w-md">
|
||||
Choose a search tool from the dropdown above to start testing queries and viewing results.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolTestPlayground;
|
||||
|
|
@ -8,14 +8,7 @@ vi.mock("@/utils/dataUtils", () => ({
|
|||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("./SearchToolTester", () => ({
|
||||
SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => (
|
||||
<div data-testid="search-tool-tester">
|
||||
<span>Search Tool Tester for {searchToolName}</span>
|
||||
<span>Access Token: {accessToken}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
describe("SearchToolView", () => {
|
||||
const mockSearchTool: SearchTool = {
|
||||
|
|
@ -260,19 +253,6 @@ describe("SearchToolView", () => {
|
|||
expect(nameCopyButton).not.toHaveClass("text-green-600");
|
||||
});
|
||||
|
||||
it("should render SearchToolTester when accessToken is provided", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render SearchToolTester when accessToken is null", () => {
|
||||
render(<SearchToolView {...defaultProps} accessToken={null} />);
|
||||
expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass correct props to SearchToolTester", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("Access Token: test-token")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Button, Card, Grid, Text, Title } from "@tremor/react";
|
|||
import { Button as AntdButton } from "antd";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
interface SearchToolViewProps {
|
||||
|
|
@ -109,15 +108,6 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search Tool Tester */}
|
||||
<div className="mt-6">
|
||||
{accessToken && (
|
||||
<SearchToolTester
|
||||
searchToolName={searchTool.search_tool_name}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ vi.mock("./CreateSearchTools", () => {
|
|||
return { default: CreateSearchTools };
|
||||
});
|
||||
|
||||
vi.mock("./SearchToolTestPlayground", () => {
|
||||
const SearchToolTestPlayground = () => (
|
||||
<div data-testid="search-tool-test-playground">Test Playground</div>
|
||||
);
|
||||
SearchToolTestPlayground.displayName = "SearchToolTestPlayground";
|
||||
return { default: SearchToolTestPlayground };
|
||||
});
|
||||
|
||||
vi.mock("../common_components/DeleteResourceModal", () => {
|
||||
const DeleteResourceModal = ({
|
||||
isOpen,
|
||||
|
|
@ -132,7 +140,7 @@ describe("SearchTools", () => {
|
|||
it("should render", async () => {
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -171,7 +179,7 @@ describe("SearchTools", () => {
|
|||
it("should show Add New Search Tool button when user is admin", async () => {
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -180,9 +188,9 @@ describe("SearchTools", () => {
|
|||
|
||||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Search Tools/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add search tool/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open create modal when Add New Search Tool button is clicked", async () => {
|
||||
|
|
@ -190,10 +198,10 @@ describe("SearchTools", () => {
|
|||
render(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add search tool/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add new search tool/i });
|
||||
const addButton = screen.getByRole("button", { name: /add search tool/i });
|
||||
await user.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { teamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Text, Title } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Spin, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form, Input, Modal, Select, Table, Tabs } from "antd";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import {
|
||||
|
|
@ -12,8 +12,10 @@ import {
|
|||
fetchSearchTools,
|
||||
updateSearchTool,
|
||||
} from "../networking";
|
||||
import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
import { searchToolColumns } from "./SearchToolColumn";
|
||||
import SearchToolTestPlayground from "./SearchToolTestPlayground";
|
||||
import { SearchToolView } from "./SearchToolView";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
|
|
@ -23,7 +25,6 @@ interface SearchToolsProps {
|
|||
userID: string | null;
|
||||
}
|
||||
|
||||
|
||||
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const {
|
||||
data: searchTools,
|
||||
|
|
@ -52,6 +53,45 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
|
||||
const availableProviders = providersResponse?.providers || [];
|
||||
|
||||
// For non-admin users, fetch their teams to scope search tools
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
const { data: userTeamsResponse } = useQuery({
|
||||
queryKey: ["userTeamsForSearchTools", userID],
|
||||
queryFn: () => {
|
||||
if (!accessToken || !userID) throw new Error("Missing auth");
|
||||
return teamListCall(accessToken, 1, 100, { userID }) as Promise<TeamsResponse>;
|
||||
},
|
||||
enabled: !!accessToken && !!userID && !isAdmin,
|
||||
});
|
||||
|
||||
// Compute allowed search tool IDs from user's teams
|
||||
const scopedSearchTools = useMemo(() => {
|
||||
if (!searchTools) return [];
|
||||
if (isAdmin) return searchTools;
|
||||
if (!userTeamsResponse?.teams) return [];
|
||||
|
||||
// Collect all search_tool IDs the user's teams grant access to
|
||||
const allowedIds = new Set<string>();
|
||||
let hasWildcard = false;
|
||||
for (const team of userTeamsResponse.teams) {
|
||||
const teamSearchTools = team.object_permission?.search_tools;
|
||||
if (!teamSearchTools) continue;
|
||||
if (teamSearchTools.includes("*")) {
|
||||
hasWildcard = true;
|
||||
break;
|
||||
}
|
||||
for (const id of teamSearchTools) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWildcard) return searchTools;
|
||||
if (allowedIds.size === 0) return [];
|
||||
return searchTools.filter(
|
||||
(tool) => allowedIds.has(tool.search_tool_id || tool.search_tool_name),
|
||||
);
|
||||
}, [searchTools, isAdmin, userTeamsResponse]);
|
||||
|
||||
// State
|
||||
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -87,8 +127,9 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
},
|
||||
handleDelete,
|
||||
availableProviders,
|
||||
isAdmin,
|
||||
),
|
||||
[availableProviders, searchTools, form],
|
||||
[availableProviders, searchTools, form, isAdmin],
|
||||
);
|
||||
|
||||
function handleDelete(toolId: string) {
|
||||
|
|
@ -176,7 +217,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
label="Search Provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
|
||||
<Select placeholder="Select a search provider" notFoundContent={isLoadingProviders ? "Loading providers..." : "No providers found"}>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option key={provider.provider_name} value={provider.provider_name}>
|
||||
{provider.ui_friendly_name}
|
||||
|
|
@ -196,7 +237,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
);
|
||||
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
|
||||
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
|
||||
}
|
||||
|
||||
|
|
@ -221,27 +261,68 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
accessToken={accessToken}
|
||||
availableProviders={availableProviders}
|
||||
/>
|
||||
) : isLoadingTools ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<AntDLoadingSpinner size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
|
||||
<Table
|
||||
bordered
|
||||
dataSource={searchTools || []}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.search_tool_id || record.search_tool_name}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No search tools configured",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
</div>
|
||||
<Table
|
||||
bordered
|
||||
dataSource={scopedSearchTools}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.search_tool_id || record.search_tool_name}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No search tools configured",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6">
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="gap-2 p-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900"><SearchOutlined style={{ marginRight: 8 }} />Search Tools</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure and manage your search providers</p>
|
||||
</div>
|
||||
{isAdminRole(userRole) && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
>
|
||||
Add Search Tool
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="tools"
|
||||
items={[
|
||||
{
|
||||
key: "tools",
|
||||
label: "Search Tools",
|
||||
children: <ToolsTab />,
|
||||
},
|
||||
{
|
||||
key: "test",
|
||||
label: "Test Search Tools",
|
||||
children: (
|
||||
<SearchToolTestPlayground
|
||||
searchTools={scopedSearchTools}
|
||||
availableProviders={availableProviders}
|
||||
isLoading={isLoadingTools}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Search Tool"
|
||||
|
|
@ -273,7 +354,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
setModalVisible={setCreateModalVisible}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title="Edit Search Tool"
|
||||
open={isEditModalVisible}
|
||||
|
|
@ -287,16 +367,6 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
>
|
||||
{renderEditForm()}
|
||||
</Modal>
|
||||
|
||||
<Title>Search Tools</Title>
|
||||
<Text className="text-tremor-content mt-2">Configure and manage your search providers</Text>
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="mt-4 mb-4" onClick={() => setCreateModalVisible(true)}>
|
||||
+ Add New Search Tool
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ToolsTab />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</span>
|
||||
{blockScope === "team" ? (
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
value={blockTeamId ?? undefined}
|
||||
onChange={(id) => setBlockTeamId(id || null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,20 @@ vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: () => ({
|
||||
data: {
|
||||
pages: [{ teams: [
|
||||
{ team_id: "team-1", team_alias: "Test Team", models: ["gpt-4"] },
|
||||
], total: 1, page: 1, page_size: 50, total_pages: 1 }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAuthorizedUser = (userRole: string, userId: string, premiumUser: boolean) => ({
|
||||
token: "test-token",
|
||||
accessToken: "test-access-token",
|
||||
|
|
@ -227,7 +241,7 @@ describe("AddModelForm", () => {
|
|||
|
||||
const teamSelect = screen.getByRole("combobox");
|
||||
await userEvent.click(teamSelect);
|
||||
await userEvent.click(screen.getByText("Test Team"));
|
||||
await userEvent.click(screen.getByText(/Test Team/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
|
|
@ -246,7 +260,7 @@ describe("AddModelForm", () => {
|
|||
|
||||
const teamSelect = screen.getByRole("combobox");
|
||||
await userEvent.click(teamSelect);
|
||||
await userEvent.click(screen.getByText("Test Team"));
|
||||
await userEvent.click(screen.getByText(/Test Team/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -131,7 +131,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
tooltip="Select the team for which you want to add this model"
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
onChange={(value) => {
|
||||
setTeamAdminSelectedTeam(value);
|
||||
}}
|
||||
|
|
@ -325,7 +324,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
},
|
||||
]}
|
||||
>
|
||||
<TeamDropdown teams={teams} disabled={!premiumUser} />
|
||||
<TeamDropdown disabled={!premiumUser} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{isAdmin && (
|
||||
|
|
|
|||
|
|
@ -723,10 +723,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
|
|||
name="team_id"
|
||||
tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team."
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
loading={!teams}
|
||||
/>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-4" />
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -556,10 +556,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams ?? []}
|
||||
value={selectedTeamId}
|
||||
onChange={(value) => setSelectedTeamId(value)}
|
||||
loading={isLoadingTeams}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!isAdmin && !selectedTeamId ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,11 @@ vi.mock("antd", () => {
|
|||
const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) =>
|
||||
React.createElement("button", { ...props, type: htmlType ?? props.type }, children);
|
||||
|
||||
const Alert = ({ message, description, ...props }: any) =>
|
||||
React.createElement("div", { role: "alert", ...props }, message, description);
|
||||
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
|
|
@ -173,6 +177,21 @@ vi.mock("antd", () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: () => ({
|
||||
data: {
|
||||
pages: [{ teams: [
|
||||
{ team_id: "team-1", team_alias: "Team One" },
|
||||
{ team_id: "team-2", team_alias: "Team Two" },
|
||||
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyCreateCall: mockKeyCreateCall,
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
|
|||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
|
||||
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import { Alert, Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from "../networking";
|
||||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import SearchToolSelector from "../SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import { simplifyKeyGenerateError } from "./utils";
|
||||
|
||||
|
|
@ -499,6 +500,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
delete formValues.allowed_agents_and_groups;
|
||||
}
|
||||
|
||||
// Always send search_tools to ensure the permission record is created.
|
||||
// Empty array = no access (least privilege for new keys).
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.search_tools = formValues.allowed_search_tool_ids || [];
|
||||
delete formValues.allowed_search_tool_ids;
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.aliases = JSON.stringify(modelAliases);
|
||||
|
|
@ -806,19 +815,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
help={keyOwner === "service_account" ? "required" : ""}
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams}
|
||||
disabled={selectedProjectId !== null}
|
||||
loading={!teams}
|
||||
onChange={(teamId) => {
|
||||
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
|
||||
setSelectedCreateKeyTeam(selectedTeam);
|
||||
organizationId={selectedOrganizationId}
|
||||
onTeamSelect={(team) => {
|
||||
setSelectedCreateKeyTeam(team);
|
||||
setSelectedProjectId(null);
|
||||
form.setFieldValue("project_id", undefined);
|
||||
// Auto-populate org from team for non-admin users
|
||||
if (selectedTeam?.organization_id) {
|
||||
setSelectedOrganizationId(selectedTeam.organization_id);
|
||||
form.setFieldValue("organization_id", selectedTeam.organization_id);
|
||||
} else if (!teamId) {
|
||||
if (team?.organization_id) {
|
||||
setSelectedOrganizationId(team.organization_id);
|
||||
form.setFieldValue("organization_id", team.organization_id);
|
||||
} else if (!team) {
|
||||
setSelectedOrganizationId(null);
|
||||
form.setFieldValue("organization_id", undefined);
|
||||
}
|
||||
|
|
@ -1424,6 +1430,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Search Tool Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New keys have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Search Tools{" "}
|
||||
<Tooltip title="Select which search tools this key can access. New keys default to no access — explicitly grant access to specific search tools.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_search_tool_ids"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_search_tool_ids", values)}
|
||||
value={form.getFieldValue("allowed_search_tool_ids")}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
allowedSearchToolIds={selectedCreateKeyTeam ? (selectedCreateKeyTeam.object_permission?.search_tools ?? []) : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
{premiumUser ? (
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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;
|
||||
|
|
@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { Alert, Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
|
|
@ -25,6 +25,7 @@ import { fetchTeamModels } from "../organisms/create_key_button";
|
|||
import NumericalInput from "../shared/numerical_input";
|
||||
import { Tag } from "../tag_management/types";
|
||||
import EditLoggingSettings from "../team/EditLoggingSettings";
|
||||
import SearchToolSelector from "../SearchTools/SearchToolSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface KeyEditViewProps {
|
||||
|
|
@ -186,6 +187,7 @@ export function KeyEditView({
|
|||
agents: keyData.object_permission?.agents || [],
|
||||
accessGroups: keyData.object_permission?.agent_access_groups || [],
|
||||
},
|
||||
search_tools: keyData.object_permission?.search_tools || [],
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
|
||||
|
|
@ -214,6 +216,7 @@ export function KeyEditView({
|
|||
accessGroups: keyData.object_permission?.mcp_access_groups || [],
|
||||
},
|
||||
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
|
||||
search_tools: keyData.object_permission?.search_tools || [],
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
|
||||
|
|
@ -614,6 +617,26 @@ export function KeyEditView({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Alert
|
||||
message="BREAKING CHANGE"
|
||||
description="New keys have no search tool access by default. Select specific tools to grant access."
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
<Form.Item
|
||||
label="Search Tools"
|
||||
name="search_tools"
|
||||
>
|
||||
<SearchToolSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("search_tools", values)}
|
||||
value={form.getFieldValue("search_tools")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (defaults to no access)"
|
||||
allowedSearchToolIds={team ? (team.object_permission?.search_tools ?? []) : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,15 @@ export default function KeyInfoView({
|
|||
delete formValues.agents_and_groups;
|
||||
}
|
||||
|
||||
// Handle search tool permissions
|
||||
if (formValues.search_tools !== undefined) {
|
||||
formValues.object_permission = {
|
||||
...formValues.object_permission,
|
||||
search_tools: formValues.search_tools || [],
|
||||
};
|
||||
delete formValues.search_tools;
|
||||
}
|
||||
|
||||
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
|
||||
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
|
||||
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue