first commit
This commit is contained in:
6
ui/app/workspace/config/api-keys/layout.tsx
Normal file
6
ui/app/workspace/config/api-keys/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import APIKeysPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/api-keys")({
|
||||
component: APIKeysPage,
|
||||
});
|
||||
9
ui/app/workspace/config/api-keys/page.tsx
Normal file
9
ui/app/workspace/config/api-keys/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import APIKeysView from "@enterprise/components/api-keys/apiKeysIndexView";
|
||||
|
||||
export default function APIKeysPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<APIKeysView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/caching/layout.tsx
Normal file
6
ui/app/workspace/config/caching/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import CachingPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/caching")({
|
||||
component: CachingPage,
|
||||
});
|
||||
9
ui/app/workspace/config/caching/page.tsx
Normal file
9
ui/app/workspace/config/caching/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import CachingView from "../views/cachingView";
|
||||
|
||||
export default function CachingPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<CachingView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/client-settings/layout.tsx
Normal file
6
ui/app/workspace/config/client-settings/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ClientSettingsPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/client-settings")({
|
||||
component: ClientSettingsPage,
|
||||
});
|
||||
9
ui/app/workspace/config/client-settings/page.tsx
Normal file
9
ui/app/workspace/config/client-settings/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ClientSettingsView from "../views/clientSettingsView";
|
||||
|
||||
export default function ClientSettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ClientSettingsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/compatibility/layout.tsx
Normal file
6
ui/app/workspace/config/compatibility/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import CompatibilityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/compatibility")({
|
||||
component: CompatibilityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/compatibility/page.tsx
Normal file
9
ui/app/workspace/config/compatibility/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import CompatibilityView from "../views/compatibilityView";
|
||||
|
||||
export default function CompatibilityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<CompatibilityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
ui/app/workspace/config/large-payload/layout.tsx
Normal file
7
ui/app/workspace/config/large-payload/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/large-payload")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/workspace/config/client-settings" });
|
||||
},
|
||||
});
|
||||
26
ui/app/workspace/config/layout.tsx
Normal file
26
ui/app/workspace/config/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { useGetCoreConfigQuery } from "@/lib/store";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import ConfigPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
|
||||
const { isLoading } = useGetCoreConfigQuery({ fromDB: true }, { skip: !hasConfigAccess });
|
||||
const childMatches = useChildMatches();
|
||||
|
||||
if (!hasConfigAccess) {
|
||||
return <NoPermissionView entity="configuration" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
return childMatches.length === 0 ? <ConfigPage /> : <Outlet />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/config")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
6
ui/app/workspace/config/logging/layout.tsx
Normal file
6
ui/app/workspace/config/logging/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import LoggingPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/logging")({
|
||||
component: LoggingPage,
|
||||
});
|
||||
9
ui/app/workspace/config/logging/page.tsx
Normal file
9
ui/app/workspace/config/logging/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LoggingView from "../views/loggingView";
|
||||
|
||||
export default function LoggingPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<LoggingView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/mcp-gateway/layout.tsx
Normal file
6
ui/app/workspace/config/mcp-gateway/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import MCPGatewayPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/mcp-gateway")({
|
||||
component: MCPGatewayPage,
|
||||
});
|
||||
9
ui/app/workspace/config/mcp-gateway/page.tsx
Normal file
9
ui/app/workspace/config/mcp-gateway/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import MCPGatewayView from "../views/mcpView";
|
||||
|
||||
export default function MCPGatewayPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<MCPGatewayView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/observability/layout.tsx
Normal file
6
ui/app/workspace/config/observability/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ObservabilityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/observability")({
|
||||
component: ObservabilityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/observability/page.tsx
Normal file
9
ui/app/workspace/config/observability/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ObservabilityView from "../views/observabilityView";
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ObservabilityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/app/workspace/config/page.tsx
Normal file
21
ui/app/workspace/config/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
// Check permission
|
||||
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConfigAccess) {
|
||||
navigate({ to: "/workspace/config/client-settings", replace: true });
|
||||
}
|
||||
}, [hasConfigAccess, navigate]);
|
||||
|
||||
if (!hasConfigAccess) {
|
||||
return <NoPermissionView entity="configuration" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
6
ui/app/workspace/config/performance-tuning/layout.tsx
Normal file
6
ui/app/workspace/config/performance-tuning/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import PerformanceTuningPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/performance-tuning")({
|
||||
component: PerformanceTuningPage,
|
||||
});
|
||||
9
ui/app/workspace/config/performance-tuning/page.tsx
Normal file
9
ui/app/workspace/config/performance-tuning/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import PerformanceTuningView from "../views/performanceTuningView";
|
||||
|
||||
export default function PerformanceTuningPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<PerformanceTuningView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/pricing-config/layout.tsx
Normal file
6
ui/app/workspace/config/pricing-config/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import PricingConfigPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/pricing-config")({
|
||||
component: PricingConfigPage,
|
||||
});
|
||||
9
ui/app/workspace/config/pricing-config/page.tsx
Normal file
9
ui/app/workspace/config/pricing-config/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ModelSettingsView from "../views/modelSettingsView";
|
||||
|
||||
export default function PricingConfigPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ModelSettingsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/proxy/layout.tsx
Normal file
6
ui/app/workspace/config/proxy/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ProxyPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/proxy")({
|
||||
component: ProxyPage,
|
||||
});
|
||||
24
ui/app/workspace/config/proxy/page.tsx
Normal file
24
ui/app/workspace/config/proxy/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import ProxyView from "../views/proxyView";
|
||||
|
||||
export default function ProxyPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ENTERPRISE) {
|
||||
navigate({ to: "/workspace/config/client-settings", replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
if (!IS_ENTERPRISE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ProxyView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/security/layout.tsx
Normal file
6
ui/app/workspace/config/security/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import SecurityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/security")({
|
||||
component: SecurityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/security/page.tsx
Normal file
9
ui/app/workspace/config/security/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import SecurityView from "../views/securityView";
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<SecurityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
ui/app/workspace/config/views/cachingView.tsx
Normal file
32
ui/app/workspace/config/views/cachingView.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getErrorMessage, useGetCoreConfigQuery } from "@/lib/store";
|
||||
import PluginsForm from "./pluginsForm";
|
||||
|
||||
export default function CachingView() {
|
||||
const { data: bifrostConfig, isLoading, error: configError } = useGetCoreConfigQuery({ fromDB: true });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Caching</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure semantic caching for requests.</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-muted-foreground">Loading configuration...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configError !== undefined && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<p className="text-destructive text-sm font-medium">Failed to load configuration</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{getErrorMessage(configError) || "An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !configError && <PluginsForm isVectorStoreEnabled={bifrostConfig?.is_cache_connected ?? false} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
570
ui/app/workspace/config/views/clientSettingsView.tsx
Normal file
570
ui/app/workspace/config/views/clientSettingsView.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useGetDroppedRequestsQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig, DefaultGlobalHeaderFilterConfig, GlobalHeaderFilterConfig } from "@/lib/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import LargePayloadSettingsFragment from "@enterprise/components/large-payload/largePayloadSettingsFragment";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useGetLargePayloadConfigQuery, useUpdateLargePayloadConfigMutation } from "@enterprise/lib/store/apis/largePayloadApi";
|
||||
import { DefaultLargePayloadConfig, LargePayloadConfig } from "@enterprise/lib/types/largePayload";
|
||||
import { Info, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Security headers that cannot be configured in allowlist/denylist
|
||||
// These headers are always blocked for security reasons regardless of configuration
|
||||
const SECURITY_HEADERS = [
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"x-bf-api-key",
|
||||
"x-bf-vk",
|
||||
];
|
||||
|
||||
// Helper to check if a header is a security header
|
||||
function isSecurityHeader(header: string): boolean {
|
||||
const h = header.toLowerCase().trim();
|
||||
// Wildcard patterns are not literal security headers
|
||||
if (h.includes("*")) return false;
|
||||
return SECURITY_HEADERS.includes(h);
|
||||
}
|
||||
|
||||
// Helper to compare header filter configs
|
||||
function headerFilterConfigEqual(a?: GlobalHeaderFilterConfig, b?: GlobalHeaderFilterConfig): boolean {
|
||||
const aAllowlist = a?.allowlist || [];
|
||||
const bAllowlist = b?.allowlist || [];
|
||||
const aDenylist = a?.denylist || [];
|
||||
const bDenylist = b?.denylist || [];
|
||||
|
||||
if (aAllowlist.length !== bAllowlist.length || aDenylist.length !== bDenylist.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return aAllowlist.every((v, i) => v === bAllowlist[i]) && aDenylist.every((v, i) => v === bDenylist[i]);
|
||||
}
|
||||
|
||||
// Helper to compare large payload configs
|
||||
function largePayloadConfigEqual(a: LargePayloadConfig, b: LargePayloadConfig): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.request_threshold_bytes === b.request_threshold_bytes &&
|
||||
a.response_threshold_bytes === b.response_threshold_bytes &&
|
||||
a.prefetch_size_bytes === b.prefetch_size_bytes &&
|
||||
a.max_payload_bytes === b.max_payload_bytes &&
|
||||
a.truncated_log_bytes === b.truncated_log_bytes
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientSettingsView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const [droppedRequests, setDroppedRequests] = useState<number>(0);
|
||||
const { data: droppedRequestsData } = useGetDroppedRequestsQuery();
|
||||
const { data: bifrostConfig, isLoading: isCoreConfigLoading } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading: isSavingCoreConfig }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
|
||||
// Large payload config state
|
||||
const { data: serverLargePayloadConfig, isLoading: isLargePayloadConfigLoading } = useGetLargePayloadConfigQuery();
|
||||
const [updateLargePayloadConfig, { isLoading: isSavingLargePayload }] = useUpdateLargePayloadConfigMutation();
|
||||
const [localLargePayloadConfig, setLocalLargePayloadConfig] = useState<LargePayloadConfig>(DefaultLargePayloadConfig);
|
||||
|
||||
const isQueriesLoading = isCoreConfigLoading || isLargePayloadConfigLoading;
|
||||
const isLoading = isSavingCoreConfig || isSavingLargePayload;
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedRequestsData) {
|
||||
setDroppedRequests(droppedRequestsData.dropped_requests);
|
||||
}
|
||||
}, [droppedRequestsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig({
|
||||
...config,
|
||||
header_filter_config: config.header_filter_config || DefaultGlobalHeaderFilterConfig,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverLargePayloadConfig) {
|
||||
setLocalLargePayloadConfig(serverLargePayloadConfig);
|
||||
}
|
||||
}, [serverLargePayloadConfig]);
|
||||
|
||||
const hasCoreConfigChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.drop_excess_requests !== config.drop_excess_requests ||
|
||||
localConfig.disable_db_pings_in_health !== config.disable_db_pings_in_health ||
|
||||
localConfig.async_job_result_ttl !== config.async_job_result_ttl ||
|
||||
!headerFilterConfigEqual(localConfig.header_filter_config, config.header_filter_config)
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const hasLargePayloadChanges = useMemo(() => {
|
||||
const baseline = serverLargePayloadConfig ?? DefaultLargePayloadConfig;
|
||||
return !largePayloadConfigEqual(localLargePayloadConfig, baseline);
|
||||
}, [serverLargePayloadConfig, localLargePayloadConfig]);
|
||||
|
||||
const hasChanges = hasCoreConfigChanges || hasLargePayloadChanges;
|
||||
|
||||
// Detect security headers in allowlist/denylist
|
||||
const invalidSecurityHeaders = useMemo(() => {
|
||||
const allowlist = localConfig.header_filter_config?.allowlist || [];
|
||||
const denylist = localConfig.header_filter_config?.denylist || [];
|
||||
const invalidInAllowlist = allowlist.filter((h) => h && isSecurityHeader(h));
|
||||
const invalidInDenylist = denylist.filter((h) => h && isSecurityHeader(h));
|
||||
return [...new Set([...invalidInAllowlist, ...invalidInDenylist])];
|
||||
}, [localConfig.header_filter_config]);
|
||||
|
||||
const hasSecurityHeaderError = invalidSecurityHeaders.length > 0;
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[] | GlobalHeaderFilterConfig) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLargePayloadConfigChange = useCallback((newConfig: LargePayloadConfig) => {
|
||||
setLocalLargePayloadConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Defense in depth - don't save if security headers are present
|
||||
if (hasSecurityHeaderError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate large payload config if it has changes
|
||||
if (hasLargePayloadChanges) {
|
||||
const minBytes = 1024;
|
||||
if (
|
||||
localLargePayloadConfig.request_threshold_bytes < minBytes ||
|
||||
localLargePayloadConfig.response_threshold_bytes < minBytes ||
|
||||
localLargePayloadConfig.prefetch_size_bytes < minBytes ||
|
||||
localLargePayloadConfig.max_payload_bytes < minBytes ||
|
||||
localLargePayloadConfig.truncated_log_bytes < minBytes
|
||||
) {
|
||||
toast.error("All byte values must be at least 1024 (1 KB).");
|
||||
return;
|
||||
}
|
||||
if (localLargePayloadConfig.max_payload_bytes < localLargePayloadConfig.request_threshold_bytes) {
|
||||
toast.error("Max payload size must be greater than or equal to the request threshold.");
|
||||
return;
|
||||
}
|
||||
if (localLargePayloadConfig.max_payload_bytes < localLargePayloadConfig.response_threshold_bytes) {
|
||||
toast.error("Max payload size must be greater than or equal to the response threshold.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let coreConfigSaved = false;
|
||||
let largePayloadSaved = false;
|
||||
|
||||
// Save core config if changed
|
||||
if (hasCoreConfigChanges) {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
// Clean up empty strings from header filter config
|
||||
const cleanedConfig = {
|
||||
...localConfig,
|
||||
header_filter_config: {
|
||||
allowlist: (localConfig.header_filter_config?.allowlist || []).filter((h) => h && h.trim().length > 0),
|
||||
denylist: (localConfig.header_filter_config?.denylist || []).filter((h) => h && h.trim().length > 0),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig!, client_config: cleanedConfig }).unwrap();
|
||||
coreConfigSaved = true;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save client config: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save large payload config if changed
|
||||
if (hasLargePayloadChanges) {
|
||||
try {
|
||||
await updateLargePayloadConfig(localLargePayloadConfig).unwrap();
|
||||
largePayloadSaved = true;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save large payload config: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (coreConfigSaved || largePayloadSaved) {
|
||||
if (largePayloadSaved) {
|
||||
toast.success("Settings updated. Large payload changes require a restart to apply.");
|
||||
} else {
|
||||
toast.success("Client settings updated successfully.");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bifrostConfig,
|
||||
hasSecurityHeaderError,
|
||||
hasCoreConfigChanges,
|
||||
hasLargePayloadChanges,
|
||||
localConfig,
|
||||
localLargePayloadConfig,
|
||||
updateCoreConfig,
|
||||
updateLargePayloadConfig,
|
||||
]);
|
||||
|
||||
// Header filter list handlers
|
||||
const handleAddAllowlistHeader = useCallback(() => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: [...(prev.header_filter_config?.allowlist || []), ""],
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveAllowlistHeader = useCallback((index: number) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: (prev.header_filter_config?.allowlist || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAllowlistChange = useCallback((index: number, value: string) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: (prev.header_filter_config?.allowlist || []).map((h, i) => (i === index ? lowerValue : h)),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAddDenylistHeader = useCallback(() => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: [...(prev.header_filter_config?.denylist || []), ""],
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveDenylistHeader = useCallback((index: number) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: (prev.header_filter_config?.denylist || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDenylistChange = useCallback((index: number, value: string) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: (prev.header_filter_config?.denylist || []).map((h, i) => (i === index ? lowerValue : h)),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Client Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure client behavior and request handling.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Drop Excess Requests */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="drop-excess-requests" className="text-sm font-medium">
|
||||
Drop Excess Requests
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If enabled, Bifrost will drop requests that exceed pool capacity.{" "}
|
||||
{localConfig.drop_excess_requests && droppedRequests > 0 ? (
|
||||
<span>
|
||||
Have dropped <b>{droppedRequests} requests</b> since last restart.
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="drop-excess-requests"
|
||||
size="md"
|
||||
checked={localConfig.drop_excess_requests}
|
||||
onCheckedChange={(checked) => handleConfigChange("drop_excess_requests", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disable DB Pings in Health */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="disable-db-pings-in-health" className="text-sm font-medium">
|
||||
Disable DB Pings in Health Check
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If enabled, the /health endpoint will skip database connectivity checks and return OK immediately.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-db-pings-in-health"
|
||||
size="md"
|
||||
checked={localConfig.disable_db_pings_in_health}
|
||||
onCheckedChange={(checked) => handleConfigChange("disable_db_pings_in_health", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
{/* Async Job Result TTL */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="async-job-result-ttl" className="text-sm font-medium">
|
||||
Async Job Result TTL (seconds)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Default time-to-live for async job results in seconds. Results are automatically cleaned up after expiry.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="async-job-result-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-32"
|
||||
value={localConfig.async_job_result_ttl}
|
||||
onChange={(e) => handleConfigChange("async_job_result_ttl", parseInt(e.target.value) || 0)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
data-testid="client-settings-async-job-result-ttl-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Filter Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Header Forwarding</h3>
|
||||
<p className="text-muted-foreground text-sm">Control which extra headers are forwarded to LLM providers.</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" className="w-full rounded-sm border px-4">
|
||||
<AccordionItem value="about-extra-headers">
|
||||
<AccordionTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
About Header Forwarding
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-medium">Two ways to forward headers:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">Prefixed headers:</span> Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-*</code> prefix. For example,{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-custom-id</code> is forwarded as{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code>.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Direct headers:</span> Any header explicitly added to the allowlist can be forwarded
|
||||
directly without the prefix (e.g.,{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-beta</code>).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-medium">How allowlist and denylist work:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">Allowlist empty:</span> Only{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-*</code> prefixed headers are forwarded
|
||||
(default behavior)
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Allowlist configured:</span> Prefixed headers filtered by allowlist, plus any direct
|
||||
header in the allowlist is forwarded
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Denylist:</span> Headers in the denylist are always blocked from forwarding
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Wildcards:</span> Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">*</code> at the end of a pattern to match prefixes
|
||||
(e.g., <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-*</code> matches all headers starting
|
||||
with <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-</code>). Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">*</code> alone to match all headers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-medium">Important:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
Allowlist/denylist entries should be the header name <span className="font-medium">without</span> the{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-</code> prefix
|
||||
</li>
|
||||
<li>
|
||||
Example: To allow <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-custom-id</code> or direct{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code>, add{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code> to the allowlist
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="security-note">
|
||||
<AccordionTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Security Note
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="text-sm">
|
||||
Some headers are always blocked for security reasons regardless of configuration. These headers cannot be added to the
|
||||
allowlist or denylist:
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 font-mono text-xs">
|
||||
proxy-authorization, cookie, host, content-length, connection, transfer-encoding, x-api-key, x-goog-api-key, x-bf-api-key,
|
||||
x-bf-vk
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Allowlist Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Allowlist</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Headers to allow. Enter names without the <code className="bg-muted rounded px-1 font-mono">x-bf-eh-</code> prefix. Any header
|
||||
in this list can also be sent directly without the prefix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(localConfig.header_filter_config?.allowlist || []).map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. anthropic-*, custom-id"
|
||||
data-testid="header-filter-allowlist-input"
|
||||
className={cn(
|
||||
"font-mono lowercase",
|
||||
isSecurityHeader(header) &&
|
||||
"border-destructive focus:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/50",
|
||||
)}
|
||||
value={header}
|
||||
onChange={(e) => handleAllowlistChange(index, e.target.value)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveAllowlistHeader(index)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddAllowlistHeader} disabled={!hasSettingsUpdateAccess}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Denylist Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Denylist</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Headers to block. Enter names without the <code className="bg-muted rounded px-1 font-mono">x-bf-eh-</code> prefix. Applies to
|
||||
both prefixed and direct header forwarding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(localConfig.header_filter_config?.denylist || []).map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. x-internal-*"
|
||||
data-testid="header-filter-denylist-input"
|
||||
className={cn(
|
||||
"font-mono lowercase",
|
||||
isSecurityHeader(header) &&
|
||||
"border-destructive focus:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/50",
|
||||
)}
|
||||
value={header}
|
||||
onChange={(e) => handleDenylistChange(index, e.target.value)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveDenylistHeader(index)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddDenylistHeader} disabled={!hasSettingsUpdateAccess}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Payload Optimization - Enterprise only */}
|
||||
<LargePayloadSettingsFragment
|
||||
config={localLargePayloadConfig}
|
||||
onConfigChange={handleLargePayloadConfigChange}
|
||||
controlsDisabled={isLoading || !hasSettingsUpdateAccess}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
{hasSecurityHeaderError ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button disabled>{isLoading ? "Saving..." : "Save Changes"}</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Remove security header{invalidSecurityHeaders.length > 1 ? "s" : ""}: {invalidSecurityHeaders.join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || isQueriesLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
ui/app/workspace/config/views/compatibilityView.tsx
Normal file
155
ui/app/workspace/config/views/compatibilityView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CompatConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function CompatibilityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config?.compat;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localCompatConfig, setLocalCompatConfig] = useState<CompatConfig>(DefaultCoreConfig.compat);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalCompatConfig(config);
|
||||
return;
|
||||
}
|
||||
setLocalCompatConfig(DefaultCoreConfig.compat);
|
||||
}, [config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const baseline = config ?? DefaultCoreConfig.compat;
|
||||
return (
|
||||
localCompatConfig.convert_text_to_chat !== baseline.convert_text_to_chat ||
|
||||
localCompatConfig.convert_chat_to_responses !== baseline.convert_chat_to_responses ||
|
||||
localCompatConfig.should_drop_params !== baseline.should_drop_params ||
|
||||
localCompatConfig.should_convert_params !== baseline.should_convert_params
|
||||
);
|
||||
}, [config, localCompatConfig]);
|
||||
|
||||
const handleCompatChange = useCallback((field: keyof CompatConfig, value: boolean) => {
|
||||
setLocalCompatConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig,
|
||||
client_config: {
|
||||
...(bifrostConfig.client_config ?? DefaultCoreConfig),
|
||||
compat: localCompatConfig,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Compatibility settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localCompatConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Compatibility</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure request conversions and compatibility fallbacks.{" "}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href="https://docs.getbifrost.ai/features/litellm-compat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="litellm-docs-link"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-convert-text-to-chat" className="text-sm font-medium">
|
||||
Convert Text to Chat
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Convert text completion requests to chat for models that only support chat.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-convert-text-to-chat"
|
||||
data-testid="compat-convert-text-to-chat"
|
||||
size="md"
|
||||
checked={localCompatConfig.convert_text_to_chat}
|
||||
onCheckedChange={(checked) => handleCompatChange("convert_text_to_chat", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-convert-chat-to-responses" className="text-sm font-medium">
|
||||
Convert Chat to Responses
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Convert chat completion requests to responses for models that only support responses.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-convert-chat-to-responses"
|
||||
data-testid="compat-convert-chat-to-responses"
|
||||
size="md"
|
||||
checked={localCompatConfig.convert_chat_to_responses}
|
||||
onCheckedChange={(checked) => handleCompatChange("convert_chat_to_responses", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-should-drop-params" className="text-sm font-medium">
|
||||
Drop Unsupported Params
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Drop unsupported parameters based on model catalog allowlist.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-should-drop-params"
|
||||
data-testid="compat-should-drop-params"
|
||||
size="md"
|
||||
checked={localCompatConfig.should_drop_params}
|
||||
onCheckedChange={(checked) => handleCompatChange("should_drop_params", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-should-convert-params" className="text-sm font-medium">
|
||||
Convert Unsupported Parameter Values
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Converts model parameter values that are not supported by the model.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-should-convert-params"
|
||||
data-testid="compat-should-convert-params"
|
||||
size="md"
|
||||
checked={localCompatConfig.should_convert_params}
|
||||
onCheckedChange={(checked) => handleCompatChange("should_convert_params", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="compat-save-button">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
ui/app/workspace/config/views/loggingView.tsx
Normal file
210
ui/app/workspace/config/views/loggingView.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoggingView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
const [loggingHeadersText, setLoggingHeadersText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig(config);
|
||||
setLoggingHeadersText(config.logging_headers?.join(", ") || "");
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.enable_logging !== config.enable_logging ||
|
||||
localConfig.disable_content_logging !== config.disable_content_logging ||
|
||||
localConfig.log_retention_days !== config.log_retention_days ||
|
||||
localConfig.hide_deleted_virtual_keys_in_filters !== config.hide_deleted_virtual_keys_in_filters ||
|
||||
JSON.stringify(localConfig.logging_headers || []) !== JSON.stringify(config.logging_headers || [])
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[]) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
if (field === "enable_logging" || field === "disable_content_logging") {
|
||||
setNeedsRestart(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoggingHeadersChange = useCallback((value: string) => {
|
||||
setLoggingHeadersText(value);
|
||||
setLocalConfig((prev) => ({ ...prev, logging_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate log retention days
|
||||
if (localConfig.log_retention_days < 1) {
|
||||
toast.error("Log retention days must be at least 1 day");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Logging configuration updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Logs Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure logging settings for requests and responses.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Logs */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="enable-logging" className="text-sm font-medium">
|
||||
Enable Logs
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enable logging of requests and responses to a SQL database. This can add 40-60mb of overhead to the system memory.
|
||||
{!bifrostConfig?.is_logs_connected && (
|
||||
<span className="text-destructive font-medium"> Requires logs store to be configured and enabled in config.json.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-logging"
|
||||
size="md"
|
||||
checked={localConfig.enable_logging && bifrostConfig?.is_logs_connected}
|
||||
disabled={!bifrostConfig?.is_logs_connected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (bifrostConfig?.is_logs_connected) {
|
||||
handleConfigChange("enable_logging", checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
|
||||
{/* Disable Content Logging - Only show when logging is enabled */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="disable-content-logging" className="text-sm font-medium">
|
||||
Disable Content Logging
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, only usage metadata (latency, cost, token count, etc.) will be logged. Request/response content will not be
|
||||
stored.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-content-logging"
|
||||
size="md"
|
||||
checked={localConfig.disable_content_logging}
|
||||
onCheckedChange={(checked) => handleConfigChange("disable_content_logging", checked)}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Retention Days */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="log-retention-days" className="text-sm font-medium">
|
||||
Log Retention Days
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Number of days to retain logs in the database. Minimum is 1 day. Older logs will be automatically deleted.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={localConfig.log_retention_days}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleConfigChange("log_retention_days", Math.max(1, value));
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="hide-deleted-virtual-keys-in-filters" className="text-sm font-medium">
|
||||
Do Not Show Deleted VirtualKeys In Filters
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, deleted virtual keys are excluded from Virtual Keys filter options in Logs, Dashboard, and MCP Logs.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-deleted-virtual-keys-in-filters"
|
||||
data-testid="hide-deleted-virtual-keys-in-filters-switch"
|
||||
size="md"
|
||||
checked={localConfig.hide_deleted_virtual_keys_in_filters}
|
||||
onCheckedChange={(checked) => handleConfigChange("hide_deleted_virtual_keys_in_filters", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logging Headers */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<label htmlFor="logging-headers" className="text-sm font-medium">
|
||||
Logging Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of request headers to capture in log metadata. Values are extracted from incoming requests and stored in
|
||||
the metadata field of log entries. Headers with the <code className="text-xs">x-bf-lh-</code> prefix are always captured
|
||||
automatically.
|
||||
</p>
|
||||
<Textarea
|
||||
id="logging-headers"
|
||||
data-testid="workspace-logging-headers-textarea"
|
||||
className="h-24"
|
||||
placeholder="X-Tenant-ID, X-Request-Source, X-Correlation-ID"
|
||||
value={loggingHeadersText}
|
||||
onChange={(e) => handleLoggingHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
334
ui/app/workspace/config/views/mcpView.tsx
Normal file
334
ui/app/workspace/config/views/mcpView.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
getErrorMessage,
|
||||
useGetCoreConfigQuery,
|
||||
useUpdateCoreConfigMutation,
|
||||
} from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function MCPView() {
|
||||
const hasSettingsUpdateAccess = useRbac(
|
||||
RbacResource.Settings,
|
||||
RbacOperation.Update,
|
||||
);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
mcp_agent_depth: string;
|
||||
mcp_tool_execution_timeout: string;
|
||||
mcp_code_mode_binding_level: string;
|
||||
mcp_tool_sync_interval: string;
|
||||
}>({
|
||||
mcp_agent_depth: "10",
|
||||
mcp_tool_execution_timeout: "30",
|
||||
mcp_code_mode_binding_level: "server",
|
||||
mcp_tool_sync_interval: "10",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
mcp_agent_depth: config?.mcp_agent_depth?.toString() || "10",
|
||||
mcp_tool_execution_timeout:
|
||||
config?.mcp_tool_execution_timeout?.toString() || "30",
|
||||
mcp_code_mode_binding_level:
|
||||
config?.mcp_code_mode_binding_level || "server",
|
||||
mcp_tool_sync_interval:
|
||||
config?.mcp_tool_sync_interval?.toString() || "10",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.mcp_agent_depth !== config.mcp_agent_depth ||
|
||||
localConfig.mcp_tool_execution_timeout !==
|
||||
config.mcp_tool_execution_timeout ||
|
||||
localConfig.mcp_code_mode_binding_level !==
|
||||
(config.mcp_code_mode_binding_level || "server") ||
|
||||
localConfig.mcp_tool_sync_interval !==
|
||||
(config.mcp_tool_sync_interval ?? 10) ||
|
||||
localConfig.mcp_disable_auto_tool_inject !==
|
||||
(config.mcp_disable_auto_tool_inject ?? false)
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleAgentDepthChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_agent_depth: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, mcp_agent_depth: numValue }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToolExecutionTimeoutChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_tool_execution_timeout: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_tool_execution_timeout: numValue,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCodeModeBindingLevelChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_code_mode_binding_level: value }));
|
||||
if (value === "server" || value === "tool") {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_code_mode_binding_level: value,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToolSyncIntervalChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_tool_sync_interval: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, mcp_tool_sync_interval: numValue }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisableAutoToolInjectChange = useCallback((checked: boolean) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_disable_auto_tool_inject: checked,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const agentDepth = Number.parseInt(localValues.mcp_agent_depth);
|
||||
const toolTimeout = Number.parseInt(
|
||||
localValues.mcp_tool_execution_timeout,
|
||||
);
|
||||
|
||||
if (isNaN(agentDepth) || agentDepth <= 0) {
|
||||
toast.error("Max agent depth must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(toolTimeout) || toolTimeout <= 0) {
|
||||
toast.error("Tool execution timeout must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig,
|
||||
client_config: localConfig,
|
||||
}).unwrap();
|
||||
toast.success("MCP settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, localValues, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto w-full max-w-7xl space-y-4"
|
||||
data-testid="mcp-settings-view"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">MCP Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure MCP (Model Context Protocol) agent and tool settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Max Agent Depth */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="mcp-agent-depth" className="text-sm font-medium">
|
||||
Max Agent Depth
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum depth for MCP agent execution.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-agent-depth"
|
||||
data-testid="mcp-agent-depth-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_agent_depth}
|
||||
onChange={(e) => handleAgentDepthChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Execution Timeout */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-tool-execution-timeout"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tool Execution Timeout (seconds)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum time in seconds for tool execution.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-tool-execution-timeout"
|
||||
data-testid="mcp-tool-timeout-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_tool_execution_timeout}
|
||||
onChange={(e) => handleToolExecutionTimeoutChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Sync Interval */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-tool-sync-interval"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tool Sync Interval (minutes)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
How often to refresh tool lists from MCP servers. Set to 0 to
|
||||
disable.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-tool-sync-interval"
|
||||
data-testid="mcp-tool-sync-interval-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_tool_sync_interval}
|
||||
onChange={(e) => handleToolSyncIntervalChange(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disable Auto Tool Injection */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-disable-auto-tool-inject"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Disable Auto Tool Injection
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, MCP tools are not automatically included in every
|
||||
request. Tools are only injected when explicitly specified via
|
||||
request headers (
|
||||
<code className="text-xs">x-bf-mcp-include-tools</code>) and still
|
||||
must be allowed by the virtual key MCP configuration.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="mcp-disable-auto-tool-inject"
|
||||
checked={localConfig.mcp_disable_auto_tool_inject ?? false}
|
||||
onCheckedChange={handleDisableAutoToolInjectChange}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
data-testid="mcp-disable-auto-tool-inject-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Code Mode Binding Level */}
|
||||
<div className="space-y-4 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="mcp-binding-level" className="text-sm font-medium">
|
||||
Code Mode Binding Level
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
How tools are exposed in the VFS: server-level (all tools per
|
||||
server) or tool-level (individual tools).
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={localValues.mcp_code_mode_binding_level}
|
||||
onValueChange={handleCodeModeBindingLevelChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="mcp-binding-level"
|
||||
data-testid="mcp-binding-level"
|
||||
className="w-56"
|
||||
>
|
||||
<SelectValue placeholder="Select binding level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="server">Server-Level</SelectItem>
|
||||
<SelectItem value="tool">Tool-Level</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Visual Example */}
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
VFS Structure:
|
||||
</p>
|
||||
|
||||
{localValues.mcp_code_mode_binding_level === "server" ? (
|
||||
<div className="bg-muted border-border rounded-sm border p-4">
|
||||
<div className="text-foreground space-y-1 font-mono text-xs">
|
||||
<div>servers/</div>
|
||||
<div className="pl-3">├─ calculator.py</div>
|
||||
<div className="pl-3">├─ youtube.py</div>
|
||||
<div className="pl-3">└─ weather.py</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
All tools per server in a single .py file
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted border-border rounded-sm border p-4">
|
||||
<div className="text-foreground space-y-1 font-mono text-xs">
|
||||
<div>servers/</div>
|
||||
<div className="pl-3">├─ calculator/</div>
|
||||
<div className="pl-6">├─ add.py</div>
|
||||
<div className="pl-6">└─ subtract.py</div>
|
||||
<div className="pl-3">├─ youtube/</div>
|
||||
<div className="pl-6">├─ GET_CHANNELS.py</div>
|
||||
<div className="pl-6">└─ SEARCH_VIDEOS.py</div>
|
||||
<div className="pl-3">└─ weather/</div>
|
||||
<div className="pl-6">└─ get_forecast.py</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
Individual .py file for each tool
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}
|
||||
data-testid="mcp-settings-save-btn"
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
ui/app/workspace/config/views/modelSettingsView.tsx
Normal file
192
ui/app/workspace/config/views/modelSettingsView.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage, useForcePricingSyncMutation, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ModelSettingsFormData {
|
||||
pricing_datasheet_url: string;
|
||||
pricing_sync_interval_hours: number;
|
||||
routing_chain_max_depth: number;
|
||||
}
|
||||
|
||||
export default function ModelSettingsView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const frameworkConfig = bifrostConfig?.framework_config;
|
||||
const clientConfig = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [forcePricingSync, { isLoading: isForceSyncing }] = useForcePricingSyncMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<ModelSettingsFormData>({
|
||||
defaultValues: {
|
||||
pricing_datasheet_url: "",
|
||||
pricing_sync_interval_hours: 24,
|
||||
routing_chain_max_depth: DefaultCoreConfig.routing_chain_max_depth,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bifrostConfig || isDirty) return;
|
||||
reset({
|
||||
pricing_datasheet_url: frameworkConfig?.pricing_url || "",
|
||||
pricing_sync_interval_hours: Math.round((frameworkConfig?.pricing_sync_interval ?? 0) / 3600) || 24,
|
||||
routing_chain_max_depth: clientConfig?.routing_chain_max_depth ?? DefaultCoreConfig.routing_chain_max_depth,
|
||||
});
|
||||
}, [frameworkConfig?.pricing_url, frameworkConfig?.pricing_sync_interval, clientConfig?.routing_chain_max_depth, isDirty, reset]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!bifrostConfig || !isDirty) return false;
|
||||
const serverUrl = frameworkConfig?.pricing_url || "";
|
||||
const serverInterval = Math.round((frameworkConfig?.pricing_sync_interval ?? 0) / 3600);
|
||||
const serverDepth = clientConfig?.routing_chain_max_depth ?? DefaultCoreConfig.routing_chain_max_depth;
|
||||
return (
|
||||
formValues.pricing_datasheet_url !== serverUrl ||
|
||||
formValues.pricing_sync_interval_hours !== serverInterval ||
|
||||
formValues.routing_chain_max_depth !== serverDepth
|
||||
);
|
||||
}, [bifrostConfig, frameworkConfig, clientConfig, formValues, isDirty]);
|
||||
|
||||
const onSubmit = async (data: ModelSettingsFormData) => {
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
framework_config: {
|
||||
...frameworkConfig,
|
||||
id: bifrostConfig?.framework_config.id || 0,
|
||||
pricing_url: data.pricing_datasheet_url,
|
||||
pricing_sync_interval: data.pricing_sync_interval_hours * 3600,
|
||||
},
|
||||
client_config: {
|
||||
...clientConfig!,
|
||||
routing_chain_max_depth: data.routing_chain_max_depth,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Model settings updated successfully.");
|
||||
reset(data);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceSync = async () => {
|
||||
try {
|
||||
await forcePricingSync().unwrap();
|
||||
toast.success("Pricing sync triggered successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-4" data-testid="model-settings-view">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Model Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure pricing and routing behaviour.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pricing Datasheet URL */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-datasheet-url">Pricing Datasheet URL</Label>
|
||||
<p className="text-muted-foreground text-sm">URL to a custom pricing datasheet. Leave empty to use default pricing.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-datasheet-url"
|
||||
type="text"
|
||||
placeholder="https://example.com/pricing.json"
|
||||
data-testid="pricing-datasheet-url-input"
|
||||
{...register("pricing_datasheet_url", {
|
||||
pattern: {
|
||||
value: /^(https?:\/\/)?((localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?|([\da-z\.-]+)\.([a-z\.]{2,6}))[\/\w \.-]*\/?$/,
|
||||
message: "Please enter a valid URL.",
|
||||
},
|
||||
validate: {
|
||||
checkIfHttp: (value) => {
|
||||
if (!value) return true;
|
||||
return value.startsWith("http://") || value.startsWith("https://") || "URL must start with http:// or https://";
|
||||
},
|
||||
},
|
||||
})}
|
||||
className={errors.pricing_datasheet_url ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.pricing_datasheet_url && <p className="text-destructive text-sm">{errors.pricing_datasheet_url.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Pricing Sync Interval */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-sync-interval">Pricing Sync Interval (hours)</Label>
|
||||
<p className="text-muted-foreground text-sm">How often to sync pricing data from the datasheet URL.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-sync-interval"
|
||||
type="number"
|
||||
data-testid="pricing-sync-interval-input"
|
||||
className={errors.pricing_sync_interval_hours ? "border-destructive" : ""}
|
||||
{...register("pricing_sync_interval_hours", {
|
||||
required: "Pricing sync interval is required",
|
||||
min: { value: 1, message: "Sync interval must be at least 1 hour" },
|
||||
max: { value: 8760, message: "Sync interval cannot exceed 8760 hours (1 year)" },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.pricing_sync_interval_hours && <p className="text-destructive text-sm">{errors.pricing_sync_interval_hours.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Routing Chain Max Depth */}
|
||||
<div className="flex items-center justify-between rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="routing-chain-max-depth">Routing Chain Max Depth</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum number of chained routing rule evaluations per request. Prevents infinite loops from circular rule definitions.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="routing-chain-max-depth"
|
||||
type="number"
|
||||
className={`w-24 ${errors.routing_chain_max_depth ? "border-destructive" : ""}`}
|
||||
data-testid="routing-chain-max-depth-input"
|
||||
{...register("routing_chain_max_depth", {
|
||||
required: "Routing chain max depth is required",
|
||||
min: { value: 1, message: "Must be at least 1" },
|
||||
max: { value: 100, message: "Cannot exceed 100" },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errors.routing_chain_max_depth && <p className="text-destructive text-sm">{errors.routing_chain_max_depth.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleForceSync}
|
||||
disabled={isForceSyncing || isLoading || hasChanges || !hasSettingsUpdateAccess}
|
||||
data-testid="pricing-force-sync-btn"
|
||||
>
|
||||
{isForceSyncing ? "Syncing..." : "Force Sync Now"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="model-settings-save-btn">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
ui/app/workspace/config/views/observabilityView.tsx
Normal file
105
ui/app/workspace/config/views/observabilityView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ObservabilityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
prometheus_labels: string;
|
||||
}>({
|
||||
prometheus_labels: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
prometheus_labels: config?.prometheus_labels?.join(", ") || "",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
const localLabels = localConfig.prometheus_labels.slice().sort().join(",");
|
||||
const serverLabels = config.prometheus_labels.slice().sort().join(",");
|
||||
return localLabels !== serverLabels;
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handlePrometheusLabelsChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, prometheus_labels: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, prometheus_labels: parseArrayFromText(value) }));
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Could not save settings: configuration not loaded.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Observability settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div className="flex items-center justify-between"></div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings require a Bifrost service restart to take effect. Current connections will continue with existing settings until
|
||||
restart.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Prometheus Labels */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="prometheus-labels" className="text-sm font-medium">
|
||||
Prometheus Labels
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Comma-separated list of custom labels to add to the Prometheus metrics.</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="prometheus-labels"
|
||||
className="h-24"
|
||||
placeholder="teamId, projectId, environment"
|
||||
value={localValues.prometheus_labels}
|
||||
onChange={(e) => handlePrometheusLabelsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
157
ui/app/workspace/config/views/performanceTuningView.tsx
Normal file
157
ui/app/workspace/config/views/performanceTuningView.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function PerformanceTuningView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
initial_pool_size: string;
|
||||
max_request_body_size_mb: string;
|
||||
}>({
|
||||
initial_pool_size: "1000",
|
||||
max_request_body_size_mb: "100",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
initial_pool_size: config?.initial_pool_size?.toString() || "1000",
|
||||
max_request_body_size_mb: config?.max_request_body_size_mb?.toString() || "100",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.initial_pool_size !== config.initial_pool_size || localConfig.max_request_body_size_mb !== config.max_request_body_size_mb
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handlePoolSizeChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, initial_pool_size: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, initial_pool_size: numValue }));
|
||||
}
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleMaxRequestBodySizeMBChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, max_request_body_size_mb: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, max_request_body_size_mb: numValue }));
|
||||
}
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const poolSize = Number.parseInt(localValues.initial_pool_size);
|
||||
const maxBodySize = Number.parseInt(localValues.max_request_body_size_mb);
|
||||
|
||||
if (isNaN(poolSize) || poolSize <= 0) {
|
||||
toast.error("Initial pool size must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(maxBodySize) || maxBodySize <= 0) {
|
||||
toast.error("Max request body size must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Performance settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, localValues, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Performance Tuning</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure performance-related settings.</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings require a Bifrost service restart to take effect. Current connections will continue with existing settings until
|
||||
restart.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Initial Pool Size */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="initial-pool-size" className="text-sm font-medium">
|
||||
Initial Pool Size
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">The initial connection pool size.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="initial-pool-size"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.initial_pool_size}
|
||||
onChange={(e) => handlePoolSizeChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
|
||||
{/* Max Request Body Size */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="max-request-body-size-mb" className="text-sm font-medium">
|
||||
Max Request Body Size (MB)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Maximum size of request body in megabytes.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="max-request-body-size-mb"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.max_request_body_size_mb}
|
||||
onChange={(e) => handleMaxRequestBodySizeMBChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
467
ui/app/workspace/config/views/pluginsForm.tsx
Normal file
467
ui/app/workspace/config/views/pluginsForm.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { getErrorMessage, useCreatePluginMutation, useGetPluginsQuery, useGetProvidersQuery, useUpdatePluginMutation } from "@/lib/store";
|
||||
import { CacheConfig, EditorCacheConfig, ModelProviderName } from "@/lib/types/config";
|
||||
import { SEMANTIC_CACHE_PLUGIN } from "@/lib/types/plugins";
|
||||
import { cacheConfigSchema } from "@/lib/types/schemas";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const defaultCacheConfig: EditorCacheConfig = {
|
||||
ttl_seconds: 300,
|
||||
threshold: 0.8,
|
||||
conversation_history_threshold: 3,
|
||||
exclude_system_prompt: false,
|
||||
cache_by_model: true,
|
||||
cache_by_provider: true,
|
||||
};
|
||||
|
||||
const toEditorCacheConfig = (config?: Partial<CacheConfig>): EditorCacheConfig => ({
|
||||
...defaultCacheConfig,
|
||||
...config,
|
||||
});
|
||||
|
||||
const normalizeCacheConfigForSave = (config: EditorCacheConfig) => {
|
||||
const normalized: Record<string, unknown> = {
|
||||
ttl_seconds: config.ttl_seconds,
|
||||
threshold: config.threshold,
|
||||
cache_by_model: config.cache_by_model,
|
||||
cache_by_provider: config.cache_by_provider,
|
||||
};
|
||||
|
||||
if (config.conversation_history_threshold !== undefined) {
|
||||
normalized.conversation_history_threshold = config.conversation_history_threshold;
|
||||
}
|
||||
if (config.exclude_system_prompt !== undefined) {
|
||||
normalized.exclude_system_prompt = config.exclude_system_prompt;
|
||||
}
|
||||
if (config.created_at !== undefined) {
|
||||
normalized.created_at = config.created_at;
|
||||
}
|
||||
if (config.updated_at !== undefined) {
|
||||
normalized.updated_at = config.updated_at;
|
||||
}
|
||||
if (config.keys !== undefined) {
|
||||
normalized.keys = config.keys;
|
||||
}
|
||||
|
||||
const provider = config.provider?.trim();
|
||||
const embeddingModel = config.embedding_model?.trim();
|
||||
|
||||
if (provider) {
|
||||
normalized.provider = provider;
|
||||
}
|
||||
if (embeddingModel) {
|
||||
normalized.embedding_model = embeddingModel;
|
||||
}
|
||||
if (config.dimension !== undefined) {
|
||||
normalized.dimension = config.dimension;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
interface PluginsFormProps {
|
||||
isVectorStoreEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function PluginsForm({ isVectorStoreEnabled }: PluginsFormProps) {
|
||||
const [cacheConfig, setCacheConfig] = useState<EditorCacheConfig>(defaultCacheConfig);
|
||||
const [originalCacheEnabled, setOriginalCacheEnabled] = useState<boolean>(false);
|
||||
const [serverCacheConfig, setServerCacheConfig] = useState<EditorCacheConfig>(defaultCacheConfig);
|
||||
const [serverCacheEnabled, setServerCacheEnabled] = useState<boolean>(false);
|
||||
|
||||
const { data: providersData, error: providersError, isLoading: providersLoading } = useGetProvidersQuery();
|
||||
|
||||
const providers = useMemo(() => providersData || [], [providersData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (providersError) {
|
||||
toast.error(`Failed to load providers: ${getErrorMessage(providersError as any)}`);
|
||||
}
|
||||
}, [providersError]);
|
||||
|
||||
// RTK Query hooks
|
||||
const { data: plugins, isLoading: loading } = useGetPluginsQuery();
|
||||
const [updatePlugin, { isLoading: isUpdating }] = useUpdatePluginMutation();
|
||||
const [createPlugin, { isLoading: isCreating }] = useCreatePluginMutation();
|
||||
|
||||
// Get semantic cache plugin and its config
|
||||
const semanticCachePlugin = useMemo(() => plugins?.find((plugin) => plugin.name === SEMANTIC_CACHE_PLUGIN), [plugins]);
|
||||
|
||||
const isSemanticCacheEnabled = Boolean(semanticCachePlugin?.enabled);
|
||||
const loadedDirectOnlyConfig = serverCacheConfig.dimension === 1 && !serverCacheConfig.provider;
|
||||
const hasInvalidProviderBackedDimension = cacheConfig.dimension === 1 && Boolean(cacheConfig.provider?.trim());
|
||||
|
||||
// Initialize cache config from plugin data
|
||||
useEffect(() => {
|
||||
if (semanticCachePlugin?.config) {
|
||||
const config = toEditorCacheConfig(semanticCachePlugin.config as Partial<CacheConfig>);
|
||||
setCacheConfig(config);
|
||||
setServerCacheConfig(config);
|
||||
setOriginalCacheEnabled(semanticCachePlugin.enabled);
|
||||
setServerCacheEnabled(semanticCachePlugin.enabled);
|
||||
}
|
||||
}, [semanticCachePlugin]);
|
||||
|
||||
// Update default provider when providers are loaded (only for new configs)
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !semanticCachePlugin?.config) {
|
||||
setCacheConfig((prev) => ({
|
||||
...prev,
|
||||
provider: providers[0].name as ModelProviderName,
|
||||
embedding_model: prev.embedding_model ?? "text-embedding-3-small",
|
||||
dimension: prev.dimension ?? 1536,
|
||||
}));
|
||||
}
|
||||
}, [providers, semanticCachePlugin?.config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (originalCacheEnabled !== serverCacheEnabled) return true;
|
||||
|
||||
return (
|
||||
cacheConfig.provider !== serverCacheConfig.provider ||
|
||||
cacheConfig.embedding_model !== serverCacheConfig.embedding_model ||
|
||||
cacheConfig.dimension !== serverCacheConfig.dimension ||
|
||||
cacheConfig.ttl_seconds !== serverCacheConfig.ttl_seconds ||
|
||||
cacheConfig.threshold !== serverCacheConfig.threshold ||
|
||||
cacheConfig.conversation_history_threshold !== serverCacheConfig.conversation_history_threshold ||
|
||||
cacheConfig.exclude_system_prompt !== serverCacheConfig.exclude_system_prompt ||
|
||||
cacheConfig.cache_by_model !== serverCacheConfig.cache_by_model ||
|
||||
cacheConfig.cache_by_provider !== serverCacheConfig.cache_by_provider
|
||||
);
|
||||
}, [cacheConfig, serverCacheConfig, originalCacheEnabled, serverCacheEnabled]);
|
||||
|
||||
// Handle semantic cache toggle (create or update)
|
||||
const handleSemanticCacheToggle = (enabled: boolean) => {
|
||||
setOriginalCacheEnabled(enabled);
|
||||
};
|
||||
|
||||
// Update cache config locally
|
||||
const updateCacheConfigLocal = (updates: Partial<EditorCacheConfig>) => {
|
||||
setCacheConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// Save all changes
|
||||
const handleSave = async () => {
|
||||
if (hasInvalidProviderBackedDimension) {
|
||||
toast.error(
|
||||
"Provider-backed semantic cache requires the embedding model's real dimension. Use a value greater than 1, or remove the provider to keep direct-only mode.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = cacheConfigSchema.safeParse(normalizeCacheConfigForSave(cacheConfig));
|
||||
if (!parseResult.success) {
|
||||
const firstIssue = parseResult.error.issues[0]?.message ?? "Semantic cache configuration is invalid.";
|
||||
toast.error(firstIssue);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedConfig = parseResult.data as CacheConfig;
|
||||
|
||||
try {
|
||||
if (semanticCachePlugin) {
|
||||
// Update existing plugin
|
||||
await updatePlugin({
|
||||
name: SEMANTIC_CACHE_PLUGIN,
|
||||
data: { enabled: originalCacheEnabled, config: savedConfig },
|
||||
}).unwrap();
|
||||
} else {
|
||||
// Create new plugin
|
||||
await createPlugin({
|
||||
name: SEMANTIC_CACHE_PLUGIN,
|
||||
enabled: originalCacheEnabled,
|
||||
config: savedConfig,
|
||||
path: "",
|
||||
}).unwrap();
|
||||
}
|
||||
toast.success("Plugin configuration updated successfully");
|
||||
// Update server state to match current state
|
||||
const normalizedConfig = toEditorCacheConfig(savedConfig);
|
||||
setCacheConfig(normalizedConfig);
|
||||
setServerCacheConfig(normalizedConfig);
|
||||
setServerCacheEnabled(originalCacheEnabled);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
toast.error(`Failed to update plugin configuration: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-muted-foreground">Loading plugins configuration...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Semantic Cache Toggle */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<label htmlFor="enable-caching" className="text-sm font-medium">
|
||||
Enable Semantic Caching
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enable semantic caching for requests. Send <b>x-bf-cache-key</b> header with requests to use semantic caching.{" "}
|
||||
{!isVectorStoreEnabled && (
|
||||
<span className="text-destructive font-medium">Requires vector store to be configured and enabled in config.json.</span>
|
||||
)}
|
||||
{!providersLoading && providers?.length === 0 && (
|
||||
<span className="text-destructive font-medium"> Requires at least one provider to be configured.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="enable-caching"
|
||||
size="md"
|
||||
checked={originalCacheEnabled && isVectorStoreEnabled}
|
||||
disabled={!isVectorStoreEnabled || providersLoading || providers.length === 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isVectorStoreEnabled) {
|
||||
handleSemanticCacheToggle(checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(isSemanticCacheEnabled || originalCacheEnabled) && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isUpdating || isCreating || hasInvalidProviderBackedDimension}
|
||||
size="sm"
|
||||
>
|
||||
{isUpdating || isCreating ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Configuration (only show when enabled) */}
|
||||
{originalCacheEnabled &&
|
||||
isVectorStoreEnabled &&
|
||||
(providersLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Separator />
|
||||
{loadedDirectOnlyConfig && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
|
||||
This plugin was loaded in direct-only mode via <code>config.json</code>. The Web UI currently edits provider-backed
|
||||
semantic cache settings; keep using <code>config.json</code> if you want to stay in direct-only mode.
|
||||
</div>
|
||||
)}
|
||||
{hasInvalidProviderBackedDimension && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-900">
|
||||
You selected a provider while keeping <code>dimension: 1</code>. That is only valid for direct-only mode. Set the
|
||||
embedding model's real dimension before saving, or remove the provider to stay in direct-only mode.
|
||||
</div>
|
||||
)}
|
||||
{/* Provider and Model Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Provider and Model Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Configured Providers</Label>
|
||||
<Select
|
||||
value={cacheConfig.provider}
|
||||
onValueChange={(value: ModelProviderName) => updateCacheConfigLocal({ provider: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers
|
||||
.filter((provider) => provider.name)
|
||||
.map((provider) => (
|
||||
<SelectItem key={provider.name} value={provider.name}>
|
||||
{getProviderLabel(provider.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedding_model">Embedding Model*</Label>
|
||||
<Input
|
||||
id="embedding_model"
|
||||
placeholder="text-embedding-3-small"
|
||||
value={cacheConfig.embedding_model ?? ""}
|
||||
onChange={(e) => updateCacheConfigLocal({ embedding_model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Cache Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ttl">TTL (seconds)</Label>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
min="1"
|
||||
value={cacheConfig.ttl_seconds === undefined || Number.isNaN(cacheConfig.ttl_seconds) ? "" : cacheConfig.ttl_seconds}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ ttl_seconds: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ ttl_seconds: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">Similarity Threshold</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={cacheConfig.threshold === undefined || Number.isNaN(cacheConfig.threshold) ? "" : cacheConfig.threshold}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ threshold: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseFloat(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ threshold: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension">Dimension</Label>
|
||||
<Input
|
||||
id="dimension"
|
||||
type="number"
|
||||
min="1"
|
||||
value={cacheConfig.dimension === undefined || Number.isNaN(cacheConfig.dimension) ? "" : cacheConfig.dimension}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ dimension: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ dimension: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
API keys for the embedding provider will be inherited from the main provider configuration. The semantic cache will use
|
||||
the configured provider's keys automatically. <b>Updates in keys will be reflected on Bifrost restart.</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conversation Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Conversation Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conversation_history_threshold">Conversation History Threshold</Label>
|
||||
<Input
|
||||
id="conversation_history_threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={cacheConfig.conversation_history_threshold || 3}
|
||||
onChange={(e) => updateCacheConfigLocal({ conversation_history_threshold: parseInt(e.target.value) || 3 })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Skip caching for conversations with more than this number of messages (prevents false positives)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-fit items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Exclude System Prompt</Label>
|
||||
<p className="text-muted-foreground text-xs">Exclude system messages from cache key generation</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.exclude_system_prompt || false}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ exclude_system_prompt: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Behavior */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Cache Behavior</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Cache by Model</Label>
|
||||
<p className="text-muted-foreground text-xs">Include model name in cache key</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.cache_by_model}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ cache_by_model: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Cache by Provider</Label>
|
||||
<p className="text-muted-foreground text-xs">Include provider name in cache key</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.cache_by_provider}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ cache_by_provider: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Notes</Label>
|
||||
<ul className="text-muted-foreground list-inside list-disc text-xs">
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-ttl</b> header with requests to use request-specific TTL.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-threshold</b> header with requests to use request-specific similarity threshold.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-type</b> header with "direct" or "semantic" to control cache behavior.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-no-store</b> header with "true" to disable response caching.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
ui/app/workspace/config/views/pricingConfigView.tsx
Normal file
164
ui/app/workspace/config/views/pricingConfigView.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage, useForcePricingSyncMutation, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PricingFormData {
|
||||
pricing_datasheet_url: string;
|
||||
pricing_sync_interval_hours: number;
|
||||
}
|
||||
|
||||
export default function PricingConfigView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.framework_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [forcePricingSync, { isLoading: isForceSyncing }] = useForcePricingSyncMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<PricingFormData>({
|
||||
defaultValues: {
|
||||
pricing_datasheet_url: "",
|
||||
pricing_sync_interval_hours: 24,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
reset({
|
||||
pricing_datasheet_url: config.pricing_url || "",
|
||||
pricing_sync_interval_hours: Math.round(config.pricing_sync_interval / 3600) || 24,
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig, reset]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config || !isDirty) return false;
|
||||
const serverUrl = config.pricing_url || "";
|
||||
const serverInterval = Math.round(config.pricing_sync_interval / 3600);
|
||||
return formValues.pricing_datasheet_url !== serverUrl || formValues.pricing_sync_interval_hours !== serverInterval;
|
||||
}, [config, formValues, isDirty]);
|
||||
|
||||
const onSubmit = async (data: PricingFormData) => {
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
framework_config: {
|
||||
...config,
|
||||
id: bifrostConfig?.framework_config.id || 0,
|
||||
pricing_url: data.pricing_datasheet_url,
|
||||
pricing_sync_interval: data.pricing_sync_interval_hours * 3600,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Pricing configuration updated successfully.");
|
||||
reset(data);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceSync = async () => {
|
||||
try {
|
||||
await forcePricingSync().unwrap();
|
||||
toast.success("Pricing sync triggered successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-4" data-testid="pricing-config-view">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Pricing Configuration</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure custom pricing datasheet and sync intervals.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pricing Datasheet URL */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-datasheet-url">Pricing Datasheet URL</Label>
|
||||
<p className="text-muted-foreground text-sm">URL to a custom pricing datasheet. Leave empty to use default pricing.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-datasheet-url"
|
||||
type="text"
|
||||
placeholder="https://example.com/pricing.json"
|
||||
data-testid="pricing-datasheet-url-input"
|
||||
{...register("pricing_datasheet_url", {
|
||||
pattern: {
|
||||
value: /^(https?:\/\/)?((localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?|([\da-z\.-]+)\.([a-z\.]{2,6}))([\/\w \.-]*)*\/?$/,
|
||||
message: "Please enter a valid URL.",
|
||||
},
|
||||
validate: {
|
||||
checkIfHttp: (value) => {
|
||||
if (!value) return true; // Allow empty
|
||||
return value.startsWith("http://") || value.startsWith("https://") || "URL must start with http:// or https://";
|
||||
},
|
||||
},
|
||||
})}
|
||||
className={errors.pricing_datasheet_url ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.pricing_datasheet_url && <p className="text-destructive text-sm">{errors.pricing_datasheet_url.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Pricing Sync Interval */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-sync-interval">Pricing Sync Interval (hours)</Label>
|
||||
<p className="text-muted-foreground text-sm">How often to sync pricing data from the datasheet URL.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-sync-interval"
|
||||
type="number"
|
||||
className={errors.pricing_sync_interval_hours ? "border-destructive" : ""}
|
||||
{...register("pricing_sync_interval_hours", {
|
||||
required: "Pricing sync interval is required",
|
||||
min: {
|
||||
value: 1,
|
||||
message: "Sync interval must be at least 1 hour",
|
||||
},
|
||||
max: {
|
||||
value: 8760,
|
||||
message: "Sync interval cannot exceed 8760 hours (1 year)",
|
||||
},
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.pricing_sync_interval_hours && (
|
||||
<p className="text-destructive text-sm">{errors.pricing_sync_interval_hours.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleForceSync}
|
||||
disabled={isForceSyncing || !hasSettingsUpdateAccess}
|
||||
data-testid="pricing-force-sync-btn"
|
||||
>
|
||||
{isForceSyncing ? "Syncing..." : "Force Sync Now"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="pricing-save-btn">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
ui/app/workspace/config/views/proxyView.tsx
Normal file
372
ui/app/workspace/config/views/proxyView.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateProxyConfigMutation } from "@/lib/store";
|
||||
import { DefaultGlobalProxyConfig, GlobalProxyConfig } from "@/lib/types/config";
|
||||
import { globalProxyConfigSchema } from "@/lib/types/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ProxyView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const proxyConfig = bifrostConfig?.proxy_config;
|
||||
const [updateProxyConfig, { isLoading }] = useUpdateProxyConfigMutation();
|
||||
|
||||
const form = useForm<GlobalProxyConfig>({
|
||||
resolver: zodResolver(globalProxyConfigSchema),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: DefaultGlobalProxyConfig,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyConfig) {
|
||||
form.reset({
|
||||
...DefaultGlobalProxyConfig,
|
||||
...proxyConfig,
|
||||
});
|
||||
}
|
||||
}, [proxyConfig, form]);
|
||||
|
||||
const watchedEnabled = form.watch("enabled");
|
||||
const watchedType = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: GlobalProxyConfig) => {
|
||||
try {
|
||||
await updateProxyConfig(data).unwrap();
|
||||
toast.success("Proxy configuration updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const isTypeUnsupported = watchedType === "socks5" || watchedType === "tcp";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Proxy Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure global proxy settings for outbound requests.</p>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!hasSettingsUpdateAccess} className="space-y-4">
|
||||
{/* Enable Proxy */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-sm font-medium">Enable Proxy</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">Enable global proxy for outbound HTTP requests.</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Proxy Configuration Section */}
|
||||
<div className={cn("space-y-4 rounded-lg border p-4 transition-opacity", !watchedEnabled && "pointer-events-none opacity-50")}>
|
||||
<h3 className="text-lg font-medium">Proxy Configuration</h3>
|
||||
|
||||
{/* Proxy Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} disabled={!watchedEnabled}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP / HTTPS</SelectItem>
|
||||
<SelectItem value="socks5" disabled>
|
||||
SOCKS5{" "}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
Coming soon
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="tcp" disabled>
|
||||
TCP{" "}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
Coming soon
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Select the proxy protocol type. Currently only HTTP proxy is supported.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTypeUnsupported && watchedEnabled && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{watchedType.toUpperCase()} proxy is not yet supported. Please use HTTP proxy.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Proxy URL */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://proxy.example.com:8080" disabled={!watchedEnabled} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Full URL of the proxy server including protocol and port.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Authentication Section */}
|
||||
<div className="bg-muted/20 space-y-4 rounded-md border p-4">
|
||||
<h4 className="text-sm font-medium">Authentication (Optional)</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Proxy username" disabled={!watchedEnabled} {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Proxy password"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div className="bg-muted/20 space-y-4 rounded-md border p-4">
|
||||
<h4 className="text-sm font-medium">Advanced Settings</h4>
|
||||
|
||||
{/* No Proxy */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="no_proxy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>No Proxy Hosts</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="localhost, 127.0.0.1, .internal.example.com"
|
||||
className="h-20"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of hosts that should bypass the proxy.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Timeout */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection Timeout (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={300}
|
||||
placeholder="30"
|
||||
className="w-32"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value !== "" ? parseInt(e.target.value, 10) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Timeout for establishing proxy connections. 0 means no timeout. Default is 60 seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* CA Certificate */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ca_cert_pem"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CA Certificate (PEM) (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
className="font-mono text-xs"
|
||||
rows={6}
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
PEM-encoded CA certificate to trust for TLS connections through SSL-intercepting proxies.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Skip TLS Verify */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-sm font-medium">Skip TLS Verification</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Disable TLS certificate verification for HTTPS proxies. Not recommended for production.
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skip_tls_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={!watchedEnabled} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Enablement Section */}
|
||||
<div className={cn("space-y-4 rounded-lg border p-4 transition-opacity", !watchedEnabled && "pointer-events-none opacity-50")}>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium">Enable Proxy For</h3>
|
||||
<p className="text-muted-foreground text-sm">Select which components should use the proxy for outbound requests.</p>
|
||||
</div>
|
||||
|
||||
{/* SCIM - Enterprise only */}
|
||||
{IS_ENTERPRISE && (
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">SCIM</FormLabel>
|
||||
<Badge variant="secondary">Enterprise</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for SCIM directory sync requests.</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_for_scim"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={!watchedEnabled} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inference - Coming Soon */}
|
||||
<div className="flex items-center justify-between rounded-md border p-4 opacity-60">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">Inference</FormLabel>
|
||||
<Badge variant="outline">Coming soon</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for LLM inference requests to model providers.</p>
|
||||
</div>
|
||||
<Switch disabled checked={false} />
|
||||
</div>
|
||||
|
||||
{/* API - Coming Soon */}
|
||||
<div className="flex items-center justify-between rounded-md border p-4 opacity-60">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">API</FormLabel>
|
||||
<Badge variant="outline">Coming soon</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for external API calls and webhooks.</p>
|
||||
</div>
|
||||
<Switch disabled checked={false} />
|
||||
</div>
|
||||
|
||||
{!IS_ENTERPRISE && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>SCIM proxy support is available in Bifrost Enterprise.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={!hasSettingsUpdateAccess ? 0 : undefined}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty || !form.formState.isValid || isLoading || !hasSettingsUpdateAccess}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasSettingsUpdateAccess && <TooltipContent>You don't have permission to update settings</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
ui/app/workspace/config/views/securityView.tsx
Normal file
421
ui/app/workspace/config/views/securityView.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EnvVarInput } from "@/components/ui/envVarInput";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { AuthConfig, CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { EnvVar } from "@/lib/types/schemas";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { validateOrigins } from "@/lib/utils/validation";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SecurityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const hideAuthDashboard = IS_ENTERPRISE;
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
allowed_origins: string;
|
||||
allowed_headers: string;
|
||||
required_headers: string;
|
||||
whitelisted_routes: string;
|
||||
}>({
|
||||
allowed_origins: "",
|
||||
allowed_headers: "",
|
||||
required_headers: "",
|
||||
whitelisted_routes: "",
|
||||
});
|
||||
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig>({
|
||||
admin_username: { value: "", env_var: "", from_env: false },
|
||||
admin_password: { value: "", env_var: "", from_env: false },
|
||||
is_enabled: false,
|
||||
disable_auth_on_inference: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
allowed_origins: config?.allowed_origins?.join(", ") || "",
|
||||
allowed_headers: config?.allowed_headers?.join(", ") || "",
|
||||
required_headers: config?.required_headers?.join(", ") || "",
|
||||
whitelisted_routes: config?.whitelisted_routes?.join(", ") || "",
|
||||
});
|
||||
}
|
||||
if (bifrostConfig?.auth_config) {
|
||||
setAuthConfig(bifrostConfig.auth_config);
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
const localOrigins = localConfig.allowed_origins?.slice().sort().join(",");
|
||||
const serverOrigins = config.allowed_origins?.slice().sort().join(",");
|
||||
const originsChanged = localOrigins !== serverOrigins;
|
||||
|
||||
const localHeaders = localConfig.allowed_headers?.slice().sort().join(",");
|
||||
const serverHeaders = config.allowed_headers?.slice().sort().join(",");
|
||||
const headersChanged = localHeaders !== serverHeaders;
|
||||
|
||||
const usernameChanged =
|
||||
authConfig.admin_username?.value !== bifrostConfig?.auth_config?.admin_username?.value ||
|
||||
authConfig.admin_username?.env_var !== bifrostConfig?.auth_config?.admin_username?.env_var ||
|
||||
authConfig.admin_username?.from_env !== bifrostConfig?.auth_config?.admin_username?.from_env;
|
||||
const passwordChanged =
|
||||
authConfig.admin_password?.value !== bifrostConfig?.auth_config?.admin_password?.value ||
|
||||
authConfig.admin_password?.env_var !== bifrostConfig?.auth_config?.admin_password?.env_var ||
|
||||
authConfig.admin_password?.from_env !== bifrostConfig?.auth_config?.admin_password?.from_env;
|
||||
const authChanged =
|
||||
authConfig.is_enabled !== bifrostConfig?.auth_config?.is_enabled ||
|
||||
usernameChanged ||
|
||||
passwordChanged ||
|
||||
authConfig.disable_auth_on_inference !== bifrostConfig?.auth_config?.disable_auth_on_inference;
|
||||
|
||||
const localRequired = localConfig.required_headers?.slice().sort().join(",");
|
||||
const serverRequired = config.required_headers?.slice().sort().join(",");
|
||||
const requiredChanged = localRequired !== serverRequired;
|
||||
|
||||
const localWhitelistedRoutes = localConfig.whitelisted_routes?.slice().sort().join(",");
|
||||
const serverWhitelistedRoutes = config.whitelisted_routes?.slice().sort().join(",");
|
||||
const whitelistedRoutesChanged = localWhitelistedRoutes !== serverWhitelistedRoutes;
|
||||
|
||||
const enforceAuthOnInferenceChanged = localConfig.enforce_auth_on_inference !== config.enforce_auth_on_inference;
|
||||
const allowDirectKeysChanged = localConfig.allow_direct_keys !== config.allow_direct_keys;
|
||||
|
||||
return (
|
||||
originsChanged ||
|
||||
headersChanged ||
|
||||
requiredChanged ||
|
||||
whitelistedRoutesChanged ||
|
||||
authChanged ||
|
||||
enforceAuthOnInferenceChanged ||
|
||||
allowDirectKeysChanged
|
||||
);
|
||||
}, [config, localConfig, authConfig, bifrostConfig]);
|
||||
|
||||
const needsRestart = useMemo(() => {
|
||||
if (!config) return false;
|
||||
|
||||
const localOrigins = localConfig.allowed_origins?.slice().sort().join(",");
|
||||
const serverOrigins = config.allowed_origins?.slice().sort().join(",");
|
||||
const originsChanged = localOrigins !== serverOrigins;
|
||||
|
||||
const localHeaders = localConfig.allowed_headers?.slice().sort().join(",");
|
||||
const serverHeaders = config.allowed_headers?.slice().sort().join(",");
|
||||
const headersChanged = localHeaders !== serverHeaders;
|
||||
|
||||
const enforceAuthOnInferenceChanged = localConfig.enforce_auth_on_inference !== config.enforce_auth_on_inference && IS_ENTERPRISE;
|
||||
|
||||
return originsChanged || headersChanged || enforceAuthOnInferenceChanged;
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleAllowedOriginsChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, allowed_origins: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, allowed_origins: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleAllowedHeadersChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, allowed_headers: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, allowed_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleRequiredHeadersChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, required_headers: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, required_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleWhitelistedRoutesChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, whitelisted_routes: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, whitelisted_routes: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleAuthToggle = useCallback((checked: boolean) => {
|
||||
setAuthConfig((prev) => ({ ...prev, is_enabled: checked }));
|
||||
}, []);
|
||||
|
||||
const handleDisableAuthOnInferenceToggle = useCallback((checked: boolean) => {
|
||||
setAuthConfig((prev) => ({ ...prev, disable_auth_on_inference: checked }));
|
||||
}, []);
|
||||
|
||||
const handleAuthFieldChange = useCallback((field: "admin_username" | "admin_password", value: EnvVar) => {
|
||||
setAuthConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const validation = validateOrigins(localConfig.allowed_origins);
|
||||
|
||||
if (!validation.isValid && localConfig.allowed_origins.length > 0) {
|
||||
toast.error(
|
||||
`Invalid origins: ${validation.invalidOrigins.join(", ")}. Origins must be valid URLs like https://example.com, wildcard patterns like https://*.example.com, or "*" to allow all origins`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasUsername = authConfig.admin_username?.value || authConfig.admin_username?.env_var;
|
||||
const hasPassword = authConfig.admin_password?.value || authConfig.admin_password?.env_var;
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
client_config: localConfig,
|
||||
auth_config: authConfig.is_enabled && hasUsername && hasPassword ? authConfig : { ...authConfig, is_enabled: false },
|
||||
}).unwrap();
|
||||
toast.success("Security settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, authConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Security Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure security and access control settings.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{authConfig.is_enabled && !authConfig.disable_auth_on_inference && (
|
||||
<Alert variant="default" className="border-blue-20">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription>
|
||||
You will need to use Basic Auth for all your inference calls (including MCP tool execution). You can disable it below. Check{" "}
|
||||
<Link to="/workspace/config/api-keys" className="text-md text-primary underline">
|
||||
API Keys
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{authConfig.is_enabled && authConfig.disable_auth_on_inference && (
|
||||
<Alert variant="default" className="border-blue-20">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription>
|
||||
Authentication is disabled for inference calls. Only dashboard, admin API and MCP tool execution calls require authentication.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Password Protect the Dashboard */}
|
||||
{!hideAuthDashboard && (
|
||||
<div>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auth-enabled" className="text-sm font-medium">
|
||||
Password protect the dashboard <Badge variant="secondary">BETA</Badge>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Set up authentication credentials to protect your Bifrost dashboard. Once configured, use the generated token for all
|
||||
admin API calls.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auth-enabled" checked={authConfig.is_enabled} onCheckedChange={handleAuthToggle} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-username">Username</Label>
|
||||
<EnvVarInput
|
||||
id="admin-username"
|
||||
type="text"
|
||||
placeholder="Enter admin username or env.VAR_NAME"
|
||||
value={authConfig.admin_username}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onChange={(value) => handleAuthFieldChange("admin_username", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-password">Password</Label>
|
||||
<EnvVarInput
|
||||
id="admin-password"
|
||||
type="password"
|
||||
placeholder="Enter admin password or env.VAR_NAME"
|
||||
value={authConfig.admin_password}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onChange={(value) => handleAuthFieldChange("admin_password", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="disable-auth-inference" className="text-sm font-medium">
|
||||
Disable authentication on inference calls
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, inference API calls (chat completions, embeddings, etc.) will not require authentication. Dashboard and
|
||||
admin API calls will still require authentication.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-auth-inference"
|
||||
className="ml-5"
|
||||
checked={authConfig.disable_auth_on_inference ?? false}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onCheckedChange={handleDisableAuthOnInferenceToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Enable Auth on Inference */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="enforce-auth-on-inference" className="text-sm font-medium">
|
||||
{IS_ENTERPRISE ? "Enable Auth on Inference" : "Enforce Virtual Keys on Inference"}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{IS_ENTERPRISE
|
||||
? "Require authentication (virtual key, API key, or user token) for all inference endpoints."
|
||||
: "Require a virtual key for all inference requests."}{" "}
|
||||
See{" "}
|
||||
<a
|
||||
href="https://docs.getbifrost.ai/features/governance/virtual-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
data-testid="security-virtual-keys-docs-link"
|
||||
>
|
||||
documentation
|
||||
</a>{" "}
|
||||
for details.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enforce-auth-on-inference"
|
||||
data-testid="enforce-auth-on-inference-switch"
|
||||
checked={localConfig.enforce_auth_on_inference}
|
||||
onCheckedChange={(checked) => handleConfigChange("enforce_auth_on_inference", checked)}
|
||||
/>
|
||||
</div>
|
||||
{/* Allowed Origins */}
|
||||
{needsRestart && <RestartWarning />}
|
||||
{/* Allow Direct API Keys */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allow-direct-keys" className="text-sm font-medium">
|
||||
Allow Direct API Keys
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Allow API keys to be passed directly in request headers (<b>Authorization</b>, <b>x-api-key</b>, or <b>x-goog-api-key</b>).
|
||||
Bifrost will directly use the key.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-direct-keys"
|
||||
checked={localConfig.allow_direct_keys}
|
||||
onCheckedChange={(checked) => handleConfigChange("allow_direct_keys", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allowed-origins" className="text-sm font-medium">
|
||||
Allowed Origins
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of allowed origins for CORS and WebSocket connections. Localhost origins are always allowed. Each
|
||||
origin must be a complete URL with protocol (e.g., https://app.example.com, http://10.0.0.100:3000). Wildcards are supported
|
||||
for subdomains (e.g., https://*.example.com) or use "*" to allow all origins.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="allowed-origins"
|
||||
className="h-24"
|
||||
placeholder="https://app.example.com, https://*.example.com, *"
|
||||
value={localValues.allowed_origins}
|
||||
onChange={(e) => handleAllowedOriginsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Allowed Headers */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allowed-headers" className="text-sm font-medium">
|
||||
Allowed Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Comma-separated list of allowed headers for CORS.</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="allowed-headers"
|
||||
className="h-24"
|
||||
placeholder="X-Stainless-Timeout"
|
||||
value={localValues.allowed_headers}
|
||||
onChange={(e) => handleAllowedHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Required Headers */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="required-headers" className="text-sm font-medium">
|
||||
Required Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of headers that must be present on every request. Requests missing any of these headers will be
|
||||
rejected with a 400 error. Header names are case-insensitive.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="required-headers"
|
||||
data-testid="required-headers-textarea"
|
||||
className="h-24"
|
||||
placeholder="X-Tenant-ID, X-Custom-Header"
|
||||
value={localValues.required_headers}
|
||||
onChange={(e) => handleRequiredHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Whitelisted Routes */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="whitelisted-routes" className="text-sm font-medium">
|
||||
Whitelisted Routes
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of routes that bypass the auth middleware. Requests to these routes will not require authentication.
|
||||
System routes like <b>/health</b>, <b>/api/session/login</b>, and <b>/api/session/is-auth-enabled</b> are always whitelisted
|
||||
regardless of this setting.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="whitelisted-routes"
|
||||
data-testid="whitelisted-routes-textarea"
|
||||
className="h-24"
|
||||
placeholder="/api/custom-webhook, /api/public-endpoint"
|
||||
value={localValues.whitelisted_routes}
|
||||
onChange={(e) => handleWhitelistedRoutesChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Need to restart Bifrost to apply changes.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user