feat(ui): add FetchTools management page and team permissions

- Create FetchTools React components (CRUD, selector, view)
- Add fetch_tools networking API functions
- Add fetch-tools page to dashboard navigation
- Add fetch_tools to team permissions (TeamInfo, OldTeams, CreateTeamModal)
- Format all files with prettier
This commit is contained in:
Thomas Menard 2026-05-03 21:30:29 +02:00
parent ad37273ba7
commit 3abab76fe5
13 changed files with 1316 additions and 351 deletions

View file

@ -27,6 +27,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
import SearchToolSelector from "@/components/SearchTools/SearchToolSelector";
import FetchToolSelector from "@/components/FetchTools/FetchToolSelector";
interface ModelAliases {
[key: string]: string;
@ -229,6 +230,10 @@ const CreateTeamModal = ({
Array.isArray(formValues.object_permission_search_tools) &&
formValues.object_permission_search_tools.length > 0;
const hasFetchTools =
Array.isArray(formValues.object_permission_fetch_tools) &&
formValues.object_permission_fetch_tools.length > 0;
if (
(formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) ||
(formValues.allowed_mcp_servers_and_groups &&
@ -236,7 +241,8 @@ const CreateTeamModal = ({
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.toolPermissions)) ||
hasAgents ||
hasSearchTools
hasSearchTools ||
hasFetchTools
) {
if (!formValues.object_permission) {
formValues.object_permission = {};
@ -278,6 +284,11 @@ const CreateTeamModal = ({
formValues.object_permission.search_tools = formValues.object_permission_search_tools;
delete formValues.object_permission_search_tools;
}
if (hasFetchTools) {
formValues.object_permission.fetch_tools = formValues.object_permission_fetch_tools;
delete formValues.object_permission_fetch_tools;
}
}
// Transform allowed_mcp_access_groups into object_permission
@ -776,6 +787,34 @@ const CreateTeamModal = ({
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Fetch Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label={
<span>
Allowed Fetch Tools{" "}
<Tooltip title="Select which fetch tools this team can access. Leave empty to allow all fetch tools.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="object_permission_fetch_tools"
className="mt-4"
help="Restrict which configured fetch tools keys on this team may call."
>
<FetchToolSelector
onChange={(vals: string[]) => form.setFieldValue("object_permission_fetch_tools", vals)}
value={form.getFieldValue("object_permission_fetch_tools")}
accessToken={accessToken || ""}
placeholder="Select fetch tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Logging Settings</b>

View file

@ -19,7 +19,13 @@ import { Team } from "@/components/key_team_helpers/key_list";
import { MCPServers } from "@/components/mcp_tools";
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import Navbar from "@/components/navbar";
import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName, getInProductNudgesCall } from "@/components/networking";
import {
getUiConfig,
Organization,
proxyBaseUrl,
setGlobalLitellmHeaderName,
getInProductNudgesCall,
} from "@/components/networking";
import NewUsagePage from "@/components/UsagePage/components/UsagePageView";
import OldTeams from "@/components/OldTeams";
import { fetchUserModels, CreateKeyPrefillData } from "@/components/organisms/create_key_button";
@ -28,6 +34,7 @@ import PassThroughSettings from "@/components/pass_through_settings";
import PromptsPanel from "@/components/prompts";
import PublicModelHub from "@/components/public_model_hub";
import { SearchTools } from "@/components/SearchTools";
import { FetchTools } from "@/components/FetchTools";
import Settings from "@/components/settings";
import { SurveyPrompt, SurveyModal, ClaudeCodePrompt, ClaudeCodeModal } from "@/components/survey";
import TagManagement from "@/components/tag_management";
@ -46,7 +53,13 @@ import ViewUserDashboard from "@/components/view_users";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
import { buildLoginUrlWithReturn, consumeReturnUrl, isValidReturnUrl, normalizeUrlForCompare, storeReturnUrl } from "@/utils/returnUrlUtils";
import {
buildLoginUrlWithReturn,
consumeReturnUrl,
isValidReturnUrl,
normalizeUrlForCompare,
storeReturnUrl,
} from "@/utils/returnUrlUtils";
import { formatUserRole, isAdminRole } from "@/utils/roles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { jwtDecode } from "jwt-decode";
@ -137,15 +150,13 @@ function CreateKeyPageContent() {
// Validate owned_by against allowed values
const validOwnedByValues = ["you", "service_account", "another_user"];
const validatedOwnedBy = ownedBy && validOwnedByValues.includes(ownedBy)
? (ownedBy as CreateKeyPrefillData["owned_by"])
: undefined;
const validatedOwnedBy =
ownedBy && validOwnedByValues.includes(ownedBy) ? (ownedBy as CreateKeyPrefillData["owned_by"]) : undefined;
// Validate key_type against allowed values
const validKeyTypes = ["default", "llm_api", "management"];
const validatedKeyType = keyType && validKeyTypes.includes(keyType)
? (keyType as CreateKeyPrefillData["key_type"])
: undefined;
const validatedKeyType =
keyType && validKeyTypes.includes(keyType) ? (keyType as CreateKeyPrefillData["key_type"]) : undefined;
// Sanitize key_alias (limit length, trim whitespace)
const sanitizedKeyAlias = keyAlias
@ -157,8 +168,8 @@ function CreateKeyPageContent() {
? modelsParam
.split(",")
.slice(0, 100) // Limit number of models to prevent DoS
.map(m => m.trim().slice(0, 256)) // Limit individual model name length
.filter(m => m.length > 0) // Remove empty strings
.map((m) => m.trim().slice(0, 256)) // Limit individual model name length
.filter((m) => m.length > 0) // Remove empty strings
: undefined;
return {
@ -359,7 +370,9 @@ function CreateKeyPageContent() {
if (accessToken && userID && userRole) {
v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
}).then((response) => setTeams(response.teams ?? [])).catch(console.error);
})
.then((response) => setTeams(response.teams ?? []))
.catch(console.error);
}
if (accessToken) {
fetchOrganizations(accessToken, setOrganizations);
@ -453,239 +466,237 @@ function CreateKeyPageContent() {
return (
<Suspense fallback={<LoadingScreen />}>
<ConfigProvider theme={{
<ConfigProvider
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
}}
>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
userEmail={userEmail}
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
<div className="flex flex-1">
<div className="mt-2">
<div className="flex flex-1">
<div className="mt-2">
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} teams={teams} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel
proxySettings={proxySettings}
/>
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} teams={teams} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
userRole={userRole}
/>
) : (
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
) : page == "workflows" ? (
<WorkflowRuns accessToken={accessToken} />
) : page == "memory" ? (
<MemoryView
accessToken={accessToken}
userID={userID}
userRole={userRole}
/>
) : page == "guardrails-monitor" ? (
<GuardrailsMonitorView accessToken={accessToken} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
</div>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "fetch-tools" ? (
<FetchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
) : page == "workflows" ? (
<WorkflowRuns accessToken={accessToken} />
) : page == "memory" ? (
<MemoryView accessToken={accessToken} userID={userID} userRole={userRole} />
) : page == "guardrails-monitor" ? (
<GuardrailsMonitorView accessToken={accessToken} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
</div>
)}
</ThemeProvider>
</ConfigProvider>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
</div>
)}
</ThemeProvider>
</ConfigProvider>
</Suspense>
);
}

View file

@ -0,0 +1,116 @@
import { LoadingOutlined } from "@ant-design/icons";
import { Form, Input, Modal, Select, Spin } from "antd";
import React, { useState } from "react";
import NotificationsManager from "../molecules/notifications_manager";
import { createFetchTool } from "../networking";
import { AvailableFetchProvider, FetchTool } from "./types";
interface CreateFetchToolProps {
accessToken: string | null;
availableProviders: AvailableFetchProvider[];
onSuccess: (newFetchTool: FetchTool) => void;
onCancel: () => void;
}
const CreateFetchTool: React.FC<CreateFetchToolProps> = ({ accessToken, availableProviders, onSuccess, onCancel }) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
if (!accessToken) return;
try {
const values = await form.validateFields();
setIsLoading(true);
const fetchToolData = {
fetch_tool_name: values.fetch_tool_name,
litellm_params: {
fetch_provider: values.fetch_provider,
api_key: values.api_key,
api_base: values.api_base,
timeout: values.timeout ? parseFloat(values.timeout) : undefined,
max_retries: values.max_retries ? parseInt(values.max_retries) : undefined,
},
fetch_tool_info: values.description
? {
description: values.description,
}
: undefined,
};
const result = await createFetchTool(accessToken, fetchToolData);
NotificationsManager.success("Fetch tool created successfully");
form.resetFields();
onSuccess(result);
} catch (error) {
console.error("Failed to create fetch tool:", error);
NotificationsManager.error("Failed to create fetch tool");
} finally {
setIsLoading(false);
}
};
return (
<Spin spinning={isLoading} indicator={<LoadingOutlined spin />} size="large">
<Form form={form} layout="vertical">
<Form.Item
name="fetch_tool_name"
label="Fetch Tool Name"
rules={[{ required: true, message: "Please enter a fetch tool name" }]}
>
<Input placeholder="e.g., my-firecrawl-fetch" />
</Form.Item>
<Form.Item
name="fetch_provider"
label="Fetch Provider"
rules={[{ required: true, message: "Please select a fetch provider" }]}
>
<Select placeholder="Select a fetch provider">
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="api_key" label="API Key" extra="API key for the fetch provider">
<Input.Password placeholder="Enter API key" />
</Form.Item>
<Form.Item name="api_base" label="API Base URL" extra="Optional: Custom API base URL for self-hosted instances">
<Input placeholder="https://api.firecrawl.dev" />
</Form.Item>
<Form.Item name="timeout" label="Timeout (seconds)">
<Input type="number" placeholder="30" />
</Form.Item>
<Form.Item name="max_retries" label="Max Retries">
<Input type="number" placeholder="3" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Description of this fetch tool" />
</Form.Item>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onCancel} className="px-4 py-2 border rounded hover:bg-gray-50" disabled={isLoading}>
Cancel
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
Create Fetch Tool
</button>
</div>
</Form>
</Spin>
);
};
export default CreateFetchTool;

View file

@ -0,0 +1,110 @@
import { Tag } from "antd";
import { ColumnsType } from "antd/es/table";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { FetchTool } from "./types";
export const fetchToolColumns = (
onView: (fetchToolId: string) => void,
onEdit: (fetchToolId: string) => void,
onDelete: (fetchToolId: string) => void,
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
): ColumnsType<FetchTool> => [
{
title: "Fetch Tool ID",
dataIndex: "fetch_tool_id",
key: "fetch_tool_id",
render: (_, tool) => {
const isFromConfig = tool.is_from_config;
if (isFromConfig) {
return <span className="text-xs">-</span>;
}
return (
<button
onClick={() => onView(tool.fetch_tool_id!)}
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left cursor-pointer max-w-40"
>
<span className="truncate block">{tool.fetch_tool_id}</span>
</button>
);
},
},
{
title: "Name",
dataIndex: "fetch_tool_name",
key: "fetch_tool_name",
render: (name: string) => <span className="font-medium">{name}</span>,
},
{
title: "Provider",
key: "provider",
render: (_, tool) => {
const provider = tool.litellm_params.fetch_provider;
const providerInfo = availableProviders.find((p) => p.provider_name === provider);
const displayName = providerInfo?.ui_friendly_name || provider;
return <span className="text-sm">{displayName}</span>;
},
},
{
title: "Created At",
dataIndex: "created_at",
key: "created_at",
render: (_, tool) => {
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
},
},
{
title: "Updated At",
dataIndex: "updated_at",
key: "updated_at",
render: (_, tool) => {
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
},
},
{
title: "Source",
key: "source",
render: (_, tool) => {
const isFromConfig = tool.is_from_config ?? false;
return <Tag color={isFromConfig ? "default" : "blue"}>{isFromConfig ? "Config" : "DB"}</Tag>;
},
},
{
title: "Actions",
key: "actions",
render: (_, tool) => {
const toolId = tool.fetch_tool_id;
const isFromConfig = tool.is_from_config ?? false;
return (
<div className="flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit fetch tool"
disabled={isFromConfig}
disabledTooltipText="Config fetch tool cannot be edited on the dashboard. Please edit it from the config file."
onClick={() => {
if (toolId && !isFromConfig) {
onEdit(toolId);
}
}}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete fetch tool"
disabled={isFromConfig}
disabledTooltipText="Config fetch tool cannot be deleted on the dashboard. Please delete it from the config file."
onClick={() => {
if (toolId && !isFromConfig) {
onDelete(toolId);
}
}}
/>
</div>
);
},
},
];

View file

@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import { fetchFetchTools } from "../networking";
export interface FetchToolSelectorProps {
onChange: (selected: string[]) => void;
value?: string[];
className?: string;
accessToken: string;
placeholder?: string;
disabled?: boolean;
}
const FetchToolSelector: React.FC<FetchToolSelectorProps> = ({
onChange,
value,
className,
accessToken,
placeholder = "Select fetch tools (optional)",
disabled = false,
}) => {
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
if (!accessToken) return;
setLoading(true);
try {
const data = await fetchFetchTools(accessToken);
const tools = Array.isArray(data?.fetch_tools) ? data.fetch_tools : Array.isArray(data?.data) ? data.data : [];
setOptions(
tools
.map((tool: { fetch_tool_name?: string }) => tool?.fetch_tool_name)
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0)
.map((name: string) => ({ label: name, value: name })),
);
} catch (e) {
console.error("Failed to load fetch tools:", e);
} finally {
setLoading(false);
}
};
load();
}, [accessToken]);
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
options={options}
style={{ width: "100%" }}
disabled={disabled}
/>
);
};
export default FetchToolSelector;

View file

@ -0,0 +1,77 @@
import React from "react";
import { Button, Tag } from "antd";
import { AvailableFetchProvider, FetchTool } from "./types";
interface FetchToolViewProps {
fetchTool: FetchTool;
onBack: () => void;
isEditing: boolean;
accessToken: string;
availableProviders: AvailableFetchProvider[];
}
const FetchToolView: React.FC<FetchToolViewProps> = ({ fetchTool, onBack, availableProviders }) => {
const providerInfo = availableProviders.find((p) => p.provider_name === fetchTool.litellm_params?.fetch_provider);
return (
<div className="w-full h-full p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold">{fetchTool.fetch_tool_name}</h2>
<p className="text-gray-500 mt-1">
{providerInfo?.ui_friendly_name || fetchTool.litellm_params?.fetch_provider}
</p>
</div>
<Button onClick={onBack}>Back to List</Button>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold mb-2">Configuration</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-gray-500">Provider:</span>
<span className="ml-2">{fetchTool.litellm_params?.fetch_provider}</span>
</div>
{fetchTool.litellm_params?.api_base && (
<div>
<span className="text-gray-500">API Base:</span>
<span className="ml-2">{fetchTool.litellm_params.api_base}</span>
</div>
)}
{fetchTool.litellm_params?.timeout && (
<div>
<span className="text-gray-500">Timeout:</span>
<span className="ml-2">{fetchTool.litellm_params.timeout}s</span>
</div>
)}
{fetchTool.litellm_params?.max_retries && (
<div>
<span className="text-gray-500">Max Retries:</span>
<span className="ml-2">{fetchTool.litellm_params.max_retries}</span>
</div>
)}
</div>
</div>
{fetchTool.fetch_tool_info?.description && (
<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold mb-2">Description</h3>
<p>{fetchTool.fetch_tool_info.description}</p>
</div>
)}
<div className="flex gap-2">
<Tag color={fetchTool.is_from_config ? "default" : "blue"}>
{fetchTool.is_from_config ? "Config" : "Database"}
</Tag>
{fetchTool.created_at && (
<Tag color="green">Created: {new Date(fetchTool.created_at).toLocaleDateString()}</Tag>
)}
</div>
</div>
</div>
);
};
export default FetchToolView;

View file

@ -0,0 +1,309 @@
import { isAdminRole } from "@/utils/roles";
import { LoadingOutlined } 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 DeleteResourceModal from "../common_components/DeleteResourceModal";
import NotificationsManager from "../molecules/notifications_manager";
import { deleteFetchTool, fetchAvailableFetchProviders, fetchFetchTools, updateFetchTool } from "../networking";
import CreateFetchTool from "./CreateFetchTool";
import { fetchToolColumns } from "./FetchToolColumn";
import { FetchToolView } from "./FetchToolView";
import { AvailableFetchProvider, FetchTool } from "./types";
interface FetchToolsProps {
accessToken: string | null;
userRole: string | null;
userID: string | null;
}
const FetchTools: React.FC<FetchToolsProps> = ({ accessToken, userRole, userID }) => {
const {
data: fetchTools,
isLoading: isLoadingTools,
refetch,
} = useQuery({
queryKey: ["fetchTools"],
queryFn: () => {
if (!accessToken) throw new Error("Access Token required");
return fetchFetchTools(accessToken).then((res) => res.fetch_tools || []);
},
enabled: !!accessToken,
}) as { data: FetchTool[]; isLoading: boolean; refetch: () => void };
const { data: providersResponse, isLoading: isLoadingProviders } = useQuery({
queryKey: ["fetchProviders"],
queryFn: () => {
if (!accessToken) throw new Error("Access Token required");
return fetchAvailableFetchProviders(accessToken);
},
enabled: !!accessToken,
}) as { data: { providers: AvailableFetchProvider[] }; isLoading: boolean };
const availableProviders = providersResponse?.providers || [];
// State
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
const [editTool, setEditTool] = useState(false);
const [isCreateModalVisible, setCreateModalVisible] = useState(false);
const [isEditModalVisible, setEditModalVisible] = useState(false);
const [form] = Form.useForm();
const columns = React.useMemo(
() =>
fetchToolColumns(
(toolId: string) => {
setSelectedToolId(toolId);
setEditTool(false);
},
(toolId: string) => {
const tool = fetchTools?.find((t) => t.fetch_tool_id === toolId);
if (tool) {
form.setFieldsValue({
fetch_tool_name: tool.fetch_tool_name,
fetch_provider: tool.litellm_params.fetch_provider,
api_key: tool.litellm_params.api_key,
api_base: tool.litellm_params.api_base,
timeout: tool.litellm_params.timeout,
max_retries: tool.litellm_params.max_retries,
description: tool.fetch_tool_info?.description,
});
setSelectedToolId(toolId);
setEditModalVisible(true);
}
},
handleDelete,
availableProviders,
),
[availableProviders, fetchTools, form],
);
function handleDelete(toolId: string) {
setToolToDelete(toolId);
setIsDeleteModalOpen(true);
}
const confirmDelete = async () => {
if (toolIdToDelete == null || accessToken == null) {
return;
}
setIsDeleting(true);
try {
await deleteFetchTool(accessToken, toolIdToDelete);
NotificationsManager.success("Deleted fetch tool successfully");
setIsDeleteModalOpen(false);
setToolToDelete(null);
refetch();
} catch (error) {
console.error("Error deleting the fetch tool:", error);
NotificationsManager.error("Failed to delete fetch tool");
} finally {
setIsDeleting(false);
}
};
const cancelDelete = () => {
setIsDeleteModalOpen(false);
setToolToDelete(null);
};
const toolToDeleteObj = fetchTools?.find((t) => t.fetch_tool_id === toolIdToDelete);
const providerInfo = toolToDeleteObj
? availableProviders.find((p) => p.provider_name === toolToDeleteObj.litellm_params.fetch_provider)
: null;
const handleCreateSuccess = (newFetchTool: FetchTool) => {
setCreateModalVisible(false);
refetch();
};
const handleEditSubmit = async () => {
if (!accessToken || !selectedToolId) return;
try {
const values = await form.validateFields();
const fetchToolData = {
fetch_tool_name: values.fetch_tool_name,
litellm_params: {
fetch_provider: values.fetch_provider,
api_key: values.api_key,
api_base: values.api_base,
timeout: values.timeout ? parseFloat(values.timeout) : undefined,
max_retries: values.max_retries ? parseInt(values.max_retries) : undefined,
},
fetch_tool_info: values.description
? {
description: values.description,
}
: undefined,
};
await updateFetchTool(accessToken, selectedToolId, fetchToolData);
NotificationsManager.success("Fetch tool updated successfully");
setEditModalVisible(false);
form.resetFields();
setSelectedToolId(null);
refetch();
} catch (error) {
console.error("Failed to update fetch tool:", error);
NotificationsManager.error("Failed to update fetch tool");
}
};
const renderEditForm = () => (
<Form form={form} layout="vertical">
<Form.Item
name="fetch_tool_name"
label="Fetch Tool Name"
rules={[{ required: true, message: "Please enter a fetch tool name" }]}
>
<Input placeholder="e.g., my-firecrawl-fetch" />
</Form.Item>
<Form.Item
name="fetch_provider"
label="Fetch Provider"
rules={[{ required: true, message: "Please select a fetch provider" }]}
>
<Select placeholder="Select a fetch provider" loading={isLoadingProviders}>
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="api_key" label="API Key" extra="API key for the fetch provider">
<Input.Password placeholder="Enter API key" />
</Form.Item>
<Form.Item name="api_base" label="API Base URL" extra="Optional: Custom API base URL for self-hosted instances">
<Input placeholder="https://api.firecrawl.dev" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Description of this fetch tool" />
</Form.Item>
</Form>
);
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>;
}
const ToolsTab = () =>
selectedToolId ? (
<FetchToolView
fetchTool={
fetchTools?.find((tool: FetchTool) => tool.fetch_tool_id === selectedToolId) || {
fetch_tool_id: "",
fetch_tool_name: "",
litellm_params: {
fetch_provider: "",
},
}
}
onBack={() => {
setEditTool(false);
setSelectedToolId(null);
refetch();
}}
isEditing={editTool}
accessToken={accessToken}
availableProviders={availableProviders}
/>
) : (
<div className="w-full h-full">
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
<Table
bordered
dataSource={fetchTools || []}
columns={columns}
rowKey={(record) => record.fetch_tool_id || record.fetch_tool_name}
pagination={false}
locale={{
emptyText: "No fetch tools configured",
}}
size="small"
/>
</Spin>
</div>
);
return (
<div className="w-full h-full p-6">
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Fetch Tool"
message="Are you sure you want to delete this fetch tool? This action cannot be undone."
resourceInformationTitle="Fetch Tool Information"
resourceInformation={[
{ label: "Fetch Tool Name", value: toolToDeleteObj?.fetch_tool_name || "" },
{
label: "Provider",
value: providerInfo?.ui_friendly_name || toolToDeleteObj?.litellm_params?.fetch_provider || "",
},
]}
onConfirm={confirmDelete}
onCancel={cancelDelete}
isDeleting={isDeleting}
/>
<div className="flex flex-col h-full" style={{ maxHeight: "100vh" }}>
<div className="flex items-center justify-between mb-4">
<div>
<Title className="text-lg">Fetch Tools</Title>
<Text>Manage your fetch tools for web content extraction</Text>
</div>
<Button type="primary" onClick={() => setCreateModalVisible(true)} className="bg-blue-600 hover:bg-blue-700">
+ Create Fetch Tool
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ToolsTab />
</div>
</div>
{/* Create Modal */}
<Modal
title="Create Fetch Tool"
open={isCreateModalVisible}
onCancel={() => setCreateModalVisible(false)}
footer={null}
width={800}
>
<CreateFetchTool
accessToken={accessToken}
availableProviders={availableProviders}
onSuccess={handleCreateSuccess}
onCancel={() => setCreateModalVisible(false)}
/>
</Modal>
{/* Edit Modal */}
<Modal
title="Edit Fetch Tool"
open={isEditModalVisible}
onCancel={() => {
setEditModalVisible(false);
form.resetFields();
setSelectedToolId(null);
}}
onOk={handleEditSubmit}
okText="Save Changes"
width={800}
>
{renderEditForm()}
</Modal>
</div>
);
};
export default FetchTools;

View file

@ -0,0 +1 @@
export { default as FetchTools } from "./FetchTools";

View file

@ -0,0 +1,36 @@
export interface FetchToolLiteLLMParams {
fetch_provider: string;
api_key?: string;
api_base?: string;
timeout?: number;
max_retries?: number;
[key: string]: any;
}
export interface FetchToolInfo {
description?: string;
[key: string]: any;
}
export interface FetchTool {
fetch_tool_id?: string;
fetch_tool_name: string;
litellm_params: FetchToolLiteLLMParams;
fetch_tool_info?: FetchToolInfo;
created_at?: string;
updated_at?: string;
is_from_config?: boolean;
}
export interface FetchToolsResponse {
fetch_tools: FetchTool[];
}
export interface AvailableFetchProvider {
provider_name: string;
ui_friendly_name: string;
}
export interface AvailableFetchProvidersResponse {
providers: AvailableFetchProvider[];
}

View file

@ -516,6 +516,10 @@ const Teams: React.FC<TeamProps> = ({
Array.isArray(formValues.object_permission_search_tools) &&
formValues.object_permission_search_tools.length > 0;
const hasFetchTools =
Array.isArray(formValues.object_permission_fetch_tools) &&
formValues.object_permission_fetch_tools.length > 0;
if (
(formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) ||
(formValues.allowed_mcp_servers_and_groups &&
@ -579,6 +583,14 @@ const Teams: React.FC<TeamProps> = ({
delete formValues.object_permission_search_tools;
}
if (hasFetchTools) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.fetch_tools = formValues.object_permission_fetch_tools;
delete formValues.object_permission_fetch_tools;
}
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.model_aliases = modelAliases;

View file

@ -13,6 +13,7 @@ import {
BookOutlined,
CreditCardOutlined,
DatabaseOutlined,
DownloadOutlined,
ExperimentOutlined,
ExportOutlined,
FileTextOutlined,
@ -32,7 +33,13 @@ import {
import type { MenuProps } from "antd";
import { ConfigProvider, Layout, Menu } from "antd";
import { useMemo } from "react";
import { all_admin_roles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyTeam, rolesWithWriteAccess } from "../utils/roles";
import {
all_admin_roles,
internalUserRoles,
isAdminRole,
isUserTeamAdminForAnyTeam,
rolesWithWriteAccess,
} from "../utils/roles";
import NewBadge from "./common_components/NewBadge";
import type { Organization } from "./networking";
import UsageIndicator from "./UsageIndicator";
@ -169,11 +176,7 @@ const menuGroups: MenuGroup[] = [
{
key: "policies",
page: "policies",
label: (
<span className="flex items-center gap-4">
Policies
</span>
),
label: <span className="flex items-center gap-4">Policies</span>,
icon: <AuditOutlined />,
roles: all_admin_roles,
},
@ -189,6 +192,12 @@ const menuGroups: MenuGroup[] = [
label: "Search Tools",
icon: <SearchOutlined />,
},
{
key: "fetch-tools",
page: "fetch-tools",
label: "Fetch Tools",
icon: <DownloadOutlined />,
},
{
key: "vector-stores",
page: "vector-stores",
@ -342,7 +351,7 @@ const menuGroups: MenuGroup[] = [
page: "usage",
label: "Old Usage",
icon: <BarChartOutlined />,
}
},
],
},
],
@ -381,7 +390,10 @@ const menuGroups: MenuGroup[] = [
page: "admin-panel",
label: (
<span className="flex items-center gap-2">
Admin Settings <NewBadge dot><span /></NewBadge>
Admin Settings{" "}
<NewBadge dot>
<span />
</NewBadge>
</span>
),
icon: <SettingOutlined />,
@ -407,7 +419,17 @@ const menuGroups: MenuGroup[] = [
},
];
const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapsed = false, enabledPagesInternalUsers, enableProjectsUI, disableAgentsForInternalUsers, allowAgentsForTeamAdmins, disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins }) => {
const Sidebar: React.FC<SidebarProps> = ({
setPage,
defaultSelectedKey,
collapsed = false,
enabledPagesInternalUsers,
enableProjectsUI,
disableAgentsForInternalUsers,
allowAgentsForTeamAdmins,
disableVectorStoresForInternalUsers,
allowVectorStoresForTeamAdmins,
}) => {
const { userId, accessToken, userRole } = useAuthorized();
const { data: organizations } = useOrganizations();
const { data: teams } = useTeams();
@ -438,11 +460,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
// Wrap label in <a> so every nav item supports right-click → "Open in new tab"
// and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks.
const renderNavLink = (
label: React.ReactNode,
page: string,
externalUrl?: string,
): React.ReactNode => {
const renderNavLink = (label: React.ReactNode, page: string, externalUrl?: string): React.ReactNode => {
if (externalUrl) {
return (
<a
@ -460,7 +478,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
const migratedRoute = MIGRATED_PAGES[page];
const href = migratedRoute
? migratedHref(migratedRoute)
: (() => { const params = new URLSearchParams(window.location.search); params.set("page", page); return `?${params.toString()}`; })();
: (() => {
const params = new URLSearchParams(window.location.search);
params.set("page", page);
return `?${params.toString()}`;
})();
return (
<a
href={href}
@ -516,8 +538,20 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
// Hide agents and vector-stores pages for non-admin users when disabled,
// unless allow_*_for_team_admins is on and the user is a team admin.
if (!isAdmin && item.key === "agents" && disableAgentsForInternalUsers && !(allowAgentsForTeamAdmins && isTeamAdmin)) return false;
if (!isAdmin && item.key === "vector-stores" && disableVectorStoresForInternalUsers && !(allowVectorStoresForTeamAdmins && isTeamAdmin)) return false;
if (
!isAdmin &&
item.key === "agents" &&
disableAgentsForInternalUsers &&
!(allowAgentsForTeamAdmins && isTeamAdmin)
)
return false;
if (
!isAdmin &&
item.key === "vector-stores" &&
disableVectorStoresForInternalUsers &&
!(allowVectorStoresForTeamAdmins && isTeamAdmin)
)
return false;
// Existing role check
if (item.roles && !item.roles.includes(userRole)) return false;
@ -526,9 +560,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) {
// If item has children, check if any children are visible
if (item.children && item.children.length > 0) {
const hasVisibleChildren = item.children.some((child) =>
enabledPagesInternalUsers.includes(child.page)
);
const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page));
if (hasVisibleChildren) {
console.log(`[LeftNav] Parent "${item.page}" (${item.key}): VISIBLE (has visible children)`);
return true;
@ -593,12 +625,12 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
})),
onClick: !item.children
? () => {
if (item.external_url) {
window.open(item.external_url, "_blank");
} else {
navigateToPage(item.page);
if (item.external_url) {
window.open(item.external_url, "_blank");
} else {
navigateToPage(item.page);
}
}
}
: undefined,
})),
});

View file

@ -7067,6 +7067,160 @@ export const testSearchToolConnection = async (accessToken: string, litellmParam
}
};
// Fetch Tools API calls
export const fetchFetchTools = async (accessToken: string) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/fetch_tools/list` : `/fetch_tools/list`;
console.log("Fetching fetch tools from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Fetched fetch tools:", data);
return data;
} catch (error) {
console.error("Failed to fetch fetch tools:", error);
throw error;
}
};
export const createFetchTool = async (accessToken: string, formValues: Record<string, any>) => {
try {
console.log("Creating fetch tool with values:", formValues);
const url = proxyBaseUrl ? `${proxyBaseUrl}/fetch_tools` : `/fetch_tools`;
const response = await fetch(url, {
method: HTTP_REQUEST.POST,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fetch_tool: formValues,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Created fetch tool:", data);
return data;
} catch (error) {
console.error("Failed to create fetch tool:", error);
throw error;
}
};
export const updateFetchTool = async (accessToken: string, fetchToolId: string, formValues: Record<string, any>) => {
try {
console.log("Updating fetch tool with ID:", fetchToolId, "values:", formValues);
const url = proxyBaseUrl ? `${proxyBaseUrl}/fetch_tools/${fetchToolId}` : `/fetch_tools/${fetchToolId}`;
const response = await fetch(url, {
method: HTTP_REQUEST.PUT,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fetch_tool: formValues,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Updated fetch tool:", data);
return data;
} catch (error) {
console.error("Failed to update fetch tool:", error);
throw error;
}
};
export const deleteFetchTool = async (accessToken: string, fetchToolId: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/fetch_tools/${fetchToolId}`;
console.log("Deleting fetch tool:", fetchToolId);
const response = await fetch(url, {
method: HTTP_REQUEST.DELETE,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Deleted fetch tool:", data);
return data;
} catch (error) {
console.error("Failed to delete fetch tool:", error);
throw error;
}
};
export const fetchAvailableFetchProviders = async (accessToken: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/fetch_tools/ui/available_providers`
: `/fetch_tools/ui/available_providers`;
console.log("Fetching available fetch providers from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Fetched available fetch providers:", data);
return data;
} catch (error) {
console.error("Failed to fetch available fetch providers:", error);
throw error;
}
};
export const listMCPTools = async (
accessToken: string,
serverId: string,

View file

@ -18,7 +18,14 @@ import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { isProxyAdminRole } from "@/utils/roles";
import { EditOutlined, GlobalOutlined, InfoCircleOutlined, MinusCircleOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import {
EditOutlined,
GlobalOutlined,
InfoCircleOutlined,
MinusCircleOutlined,
PlusOutlined,
SaveOutlined,
} from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Accordion, AccordionBody, AccordionHeader, Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tag, Tooltip } from "antd";
@ -43,6 +50,7 @@ import ObjectPermissionsView from "../object_permissions_view";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import SearchToolSelector from "../SearchTools/SearchToolSelector";
import FetchToolSelector from "../FetchTools/FetchToolSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
import MemberModal from "./EditMembership";
@ -120,6 +128,7 @@ export interface TeamData {
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
fetch_tools?: string[];
};
team_member_budget_table: {
max_budget: number;
@ -227,10 +236,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(
() => getTeamInfoDefaultTab(editTeam, canEditTeam),
[editTeam, canEditTeam]
);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const fetchTeamInfo = async () => {
try {
@ -322,7 +328,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
console.error(`Failed to fetch guardrails for policy ${policyName}:`, error);
guardrailsMap[policyName] = [];
}
})
}),
);
setPolicyGuardrails(guardrailsMap);
} catch (error) {
@ -498,9 +504,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const killSwitchOnAtSave = values.disable_global_guardrails === true;
const optedOutGlobalGuardrails = killSwitchOnAtSave
? Array.from(globalGuardrailNames)
: Array.from(globalGuardrailNames).filter(
(n) => !(values.guardrails || []).includes(n),
);
: Array.from(globalGuardrailNames).filter((n) => !(values.guardrails || []).includes(n));
const updateData: any = {
team_id: teamId,
@ -522,16 +526,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
soft_budget_alerting_emails:
typeof values.soft_budget_alerting_emails === "string"
? values.soft_budget_alerting_emails
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email.length > 0)
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email.length > 0)
: values.soft_budget_alerting_emails || [],
...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}),
},
...(values.policies?.length > 0 ? { policies: values.policies } : {}),
...(values.organization_id !== info.organization_id
? { organization_id: values.organization_id ?? null }
: {}),
...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}),
};
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
@ -599,6 +601,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.object_permission.search_tools = values.object_permission_search_tools;
}
if (Array.isArray(values.object_permission_fetch_tools)) {
updateData.object_permission.fetch_tools = values.object_permission_fetch_tools;
}
// Pass access_group_ids to the update request
if (values.access_group_ids !== undefined) {
updateData.access_group_ids = values.access_group_ids;
@ -620,8 +626,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
!(Array.isArray(value) && value.length === 0);
const hasNewValues = Object.values(currentRouterSettings.router_settings).some(isMeaningfulValue);
const hadExistingSettings = info.router_settings &&
Object.values(info.router_settings).some(isMeaningfulValue);
const hadExistingSettings = info.router_settings && Object.values(info.router_settings).some(isMeaningfulValue);
// Send if there are new values OR if the user is clearing existing ones
if (hasNewValues || hadExistingSettings) {
@ -659,10 +664,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
);
const effectiveGuardrails: string[] = initialKillSwitchOn
? nonGlobalOptIns
: [
...Array.from(globalGuardrailNames).filter((n) => !optedOutGlobals.has(n)),
...nonGlobalOptIns,
];
: [...Array.from(globalGuardrailNames).filter((n) => !optedOutGlobals.has(n)), ...nonGlobalOptIns];
const preventTagMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
@ -699,12 +701,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<Button
type="text"
icon={<ArrowLeftIcon className="h-4 w-4" />}
onClick={onClose}
className="mb-4"
>
<Button type="text" icon={<ArrowLeftIcon className="h-4 w-4" />} onClick={onClose} className="mb-4">
Back to Teams
</Button>
<Title>{info.team_alias}</Title>
@ -715,10 +712,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
size="small"
icon={copiedStates["team-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(info.team_id, "team-id")}
className={`left-2 z-10 transition-all duration-200 ${copiedStates["team-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["team-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
</div>
</div>
@ -868,13 +866,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{
key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS],
children: (
<TeamVirtualKeysTable
teamId={teamId}
teamAlias={info.team_alias}
organization={organization}
/>
),
children: <TeamVirtualKeysTable teamId={teamId} teamAlias={info.team_alias} organization={organization} />,
},
{
key: TEAM_INFO_TAB_KEYS.MEMBERS,
@ -893,9 +885,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{
key: TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS],
children: (
<MemberPermissions teamId={teamId} accessToken={accessToken} canEditTeam={canEditTeam} />
),
children: <MemberPermissions teamId={teamId} accessToken={accessToken} canEditTeam={canEditTeam} />,
},
{
key: TEAM_INFO_TAB_KEYS.SETTINGS,
@ -905,7 +895,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div className="flex justify-between items-center mb-4">
<Title>Team Settings</Title>
{canEditTeam && !isEditing && (
<Button icon={<EditOutlined className="h-4 w-4" />} onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button icon={<EditOutlined className="h-4 w-4" />} onClick={() => setIsEditing(true)}>
Edit Settings
</Button>
)}
</div>
@ -933,6 +925,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
object_permission_search_tools: info.object_permission?.search_tools || [],
object_permission_fetch_tools: info.object_permission?.fetch_tools || [],
modelLimits: Array.from(
new Set([
...Object.keys(info.metadata?.model_tpm_limit ?? {}),
@ -953,16 +946,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
guardrails: effectiveGuardrails,
policies: info.policies || [],
disable_global_guardrails: info.metadata?.disable_global_guardrails || false,
soft_budget_alerting_emails:
Array.isArray(info.metadata?.soft_budget_alerting_emails)
? info.metadata.soft_budget_alerting_emails.join(", ")
: "",
soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
? info.metadata.soft_budget_alerting_emails.join(", ")
: "",
metadata: info.metadata
? JSON.stringify(
(({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata),
null,
2,
)
(({
logging,
secret_manager_settings,
soft_budget_alerting_emails,
model_tpm_limit,
model_rpm_limit,
...rest
}) => rest)(info.metadata),
null,
2,
)
: "",
logging_settings: info.metadata?.logging || [],
secret_manager_settings: info.metadata?.secret_manager_settings
@ -1008,7 +1007,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
options={{
includeSpecialOptions: true,
includeUserModels: !teamData?.team_info?.organization_id,
showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id,
showAllProxyModelsOverride:
isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id,
}}
context="team"
dataTestId="models-select"
@ -1037,7 +1037,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</AccordionHeader>
<AccordionBody>
<Text className="text-xs text-gray-500 mb-4">
Optional defaults applied when members join this team. All fields can be overridden per member.
Optional defaults applied when members join this team. All fields can be overridden per
member.
</Text>
<Form.Item
label={
@ -1126,11 +1127,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "model"]}
@ -1140,9 +1137,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("modelLimits") ?? [];
const dupes = all.filter(
(entry: { model?: string }) => entry?.model === value,
);
const dupes = all.filter((entry: { model?: string }) => entry?.model === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate model"));
}
@ -1182,19 +1177,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Model Limit
</Button>
</Form.Item>
@ -1260,11 +1247,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{(guardrailsData?.guardrails ?? [])
.filter((g) => !g.litellm_params?.default_on)
.map((g) => (
<Select.Option
key={g.guardrail_name}
value={g.guardrail_name}
label={g.guardrail_name}
>
<Select.Option key={g.guardrail_name} value={g.guardrail_name} label={g.guardrail_name}>
{g.guardrail_name}
</Select.Option>
))}
@ -1406,6 +1389,26 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Fetch Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label="Allowed Fetch Tools"
name="object_permission_fetch_tools"
tooltip="Select which fetch tools this team can access. Leave empty to allow all fetch tools."
>
<FetchToolSelector
onChange={(vals: string[]) => form.setFieldValue("object_permission_fetch_tools", vals)}
value={form.getFieldValue("object_permission_fetch_tools")}
accessToken={accessToken || ""}
placeholder="Select fetch tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Form.Item label="Organization" name="organization_id">
<Select
allowClear
@ -1466,7 +1469,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Button onClick={() => setIsEditing(false)} disabled={isTeamSaving}>
Cancel
</Button>
<Button icon={<SaveOutlined className="h-4 w-4" />} type="primary" htmlType="submit" loading={isTeamSaving}>
<Button
icon={<SaveOutlined className="h-4 w-4" />}
type="primary"
htmlType="submit"
loading={isTeamSaving}
>
Save Changes
</Button>
</div>
@ -1545,9 +1553,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{info.metadata?.soft_budget_alerting_emails &&
Array.isArray(info.metadata.soft_budget_alerting_emails) &&
info.metadata.soft_budget_alerting_emails.length > 0 && (
<div>
Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")}
</div>
<div>Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")}</div>
)}
</div>
<div>
@ -1565,14 +1571,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
<div>
<Text className="font-medium">Router Settings</Text>
{info.router_settings && Object.values(info.router_settings).some(
(v) => v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0)
{info.router_settings &&
Object.values(info.router_settings).some(
(v) => v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
) ? (
<div className="mt-1 space-y-1">
{info.router_settings.routing_strategy && (
<div>
Routing Strategy:{" "}
<Badge color="blue">{info.router_settings.routing_strategy}</Badge>
Routing Strategy: <Badge color="blue">{info.router_settings.routing_strategy}</Badge>
</div>
)}
{info.router_settings.num_retries != null && (
@ -1584,18 +1590,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{info.router_settings.cooldown_time != null && (
<div>Cooldown Time: {info.router_settings.cooldown_time}s</div>
)}
{info.router_settings.timeout != null && (
<div>Timeout: {info.router_settings.timeout}s</div>
)}
{info.router_settings.timeout != null && <div>Timeout: {info.router_settings.timeout}s</div>}
{info.router_settings.retry_after != null && (
<div>Retry After: {info.router_settings.retry_after}s</div>
)}
{info.router_settings.fallbacks && Array.isArray(info.router_settings.fallbacks) && info.router_settings.fallbacks.length > 0 && (
<div>Fallbacks: {info.router_settings.fallbacks.length} configured</div>
)}
{info.router_settings.enable_tag_filtering && (
<div>Tag Filtering: Enabled</div>
)}
{info.router_settings.fallbacks &&
Array.isArray(info.router_settings.fallbacks) &&
info.router_settings.fallbacks.length > 0 && (
<div>Fallbacks: {info.router_settings.fallbacks.length} configured</div>
)}
{info.router_settings.enable_tag_filtering && <div>Tag Filtering: Enabled</div>}
</div>
) : (
<div className="text-gray-400">No router settings configured</div>
@ -1641,13 +1645,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</pre>
</div>
)}
</div>
)}
</Card>
),
},
].filter(tab => visibleTabs.includes(tab.key))}
].filter((tab) => visibleTabs.includes(tab.key))}
/>
<MemberModal