first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

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

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

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

View 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>;
};

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

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

View 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>;
};

View 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>;
};

View 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&apos;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&apos;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 &quot;direct&quot; or &quot;semantic&quot; to control cache behavior.
</li>
<li>
You can pass <b>x-bf-cache-no-store</b> header with &quot;true&quot; to disable response caching.
</li>
</ul>
</div>
</div>
))}
</div>
</div>
);
}

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

View 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-----&#10;...&#10;-----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>
);
}

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