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

43
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# build output
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
# auto-generated TanStack Router route tree
/app/routeTree.gen.ts

1
ui/.npmrc Normal file
View File

@@ -0,0 +1 @@
save-exact=true

11
ui/.oxfmtrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 140,
"singleQuote": false,
"semi": true,
"useTabs": true,
"tabWidth": 2,
"trailingComma": "all",
"sortTailwindcss": true,
"ignorePatterns": ["out/**", "node_modules/**"]
}

17
ui/.oxlintrc.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"env": {
"browser": true,
"es2024": true
},
"categories": {
"correctness": "warn"
},
"rules": {
"import/no-cycle": ["warn", { "maxDepth": 1, "ignoreExternal": true }],
"typescript/no-explicit-any": "warn",
"typescript/ban-ts-comment": "off"
},
"ignorePatterns": ["out/**", "node_modules/**", "*.config.*"]
}

244
ui/README.md Normal file
View File

@@ -0,0 +1,244 @@
# Bifrost UI
A modern, production-ready web interface for the [Bifrost AI Gateway](https://github.com/maximhq/bifrost) - providing real-time monitoring, configuration management, and comprehensive observability for your AI infrastructure.
## Overview
Bifrost UI is a React + Vite + TanStack Router web dashboard that serves as the control center for your Bifrost AI Gateway. It provides an intuitive interface to monitor AI requests, configure providers, manage MCP clients, and analyze performance metrics.
### Key Features
- **Real-time Log Monitoring** - Live streaming dashboard with WebSocket integration
- **Provider Management** - Configure [15+ AI providers](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)
- **MCP Integration** - Manage [Model Context Protocol](https://docs.getbifrost.ai/features/mcp) clients for advanced AI capabilities
- **Plugin System** - Extend functionality with [custom plugins](https://docs.getbifrost.ai/plugins/getting-started)
- **Analytics Dashboard** - Request metrics, success rates, latency tracking, and token usage
- **Modern UI** - Dark/light mode, responsive design, and accessible components
- **Documentation Hub** - Built-in documentation browser and quick-start guides
## Quick Start
### Prerequisites
The UI is designed to work with the Bifrost HTTP transport backend. Get started with the complete setup:
**[Gateway Setup Guide →](https://docs.getbifrost.ai/quickstart/gateway/setting-up)**
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
The development server runs on `http://localhost:3000` and connects to your Bifrost HTTP transport backend (default: `http://localhost:8080`).
### Environment Variables
```bash
# Development only - customize Bifrost backend port
BIFROST_PORT=8080
```
## Architecture
### Technology Stack
- **Framework**: React 19 + Vite + TanStack Router
- **Language**: TypeScript
- **Styling**: Tailwind CSS + Radix UI components
- **State Management**: Redux Toolkit with RTK Query
- **Real-time**: WebSocket integration
- **HTTP Client**: Axios with typed service layer
- **Theme**: Dark/light mode support
### Integration Model
```
┌─────────────────┐ HTTP/WebSocket ┌──────────────────┐
│ Bifrost UI │ ◄─────────────────► │ Bifrost HTTP │
│ (React+Vite) │ │ Transport (Go) │
└─────────────────┘ └──────────────────┘
│ │
│ Build artifacts │
└────────────────────────────────────────┘
```
- **Development**: UI runs on port 3000, connects to Go backend on port 8080
- **Production**: UI built as static assets served directly by Go HTTP transport
- **Communication**: REST API + WebSocket for real-time features
## Features
### Real-time Log Monitoring
The main dashboard provides comprehensive request monitoring with live updates via WebSocket, advanced filtering, and detailed request/response inspection.
**[Learn More →](https://docs.getbifrost.ai/features/observability)**
### Provider Configuration
Manage all your AI providers from a unified interface with support for multiple API keys, custom network configuration, and provider-specific settings.
**[View All Providers →](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)**
### MCP Client Management
Model Context Protocol integration for advanced AI capabilities including tool integration and connection monitoring.
**[MCP Documentation →](https://docs.getbifrost.ai/features/mcp)**
### Plugin Ecosystem
Extend Bifrost with powerful plugins for observability, testing, caching, and custom functionality.
**Available Plugins:**
- [Maxim Logger](https://docs.getbifrost.ai/features/observability/maxim) - Advanced LLM observability
- [Response Mocker](https://docs.getbifrost.ai/features/plugins/mocker) - Mock responses for testing
- [Semantic Cache](https://docs.getbifrost.ai/features/semantic-caching) - Intelligent response caching
- [OpenTelemetry](https://docs.getbifrost.ai/features/observability/otel) - Distributed tracing
**[Plugin Development Guide →](https://docs.getbifrost.ai/plugins/getting-started)**
## Development
### Project Structure
```
ui/
├── app/ # TanStack Router pages
│ ├── page.tsx # Main logs dashboard
│ ├── config/ # Provider & MCP configuration
│ ├── docs/ # Documentation browser
│ └── plugins/ # Plugin management
├── components/ # Reusable UI components
│ ├── logs/ # Log monitoring components
│ ├── config/ # Configuration forms
│ └── ui/ # Base UI components (Radix)
├── hooks/ # Custom React hooks
├── lib/ # Utilities and services
│ ├── store/ # Redux store and API slices
│ ├── types/ # TypeScript definitions
│ └── utils/ # Helper functions
└── scripts/ # Build and deployment scripts
```
### API Integration
The UI uses Redux Toolkit + RTK Query for state management and API communication with the Bifrost HTTP transport backend:
```typescript
// Example API usage with RTK Query
import { useGetLogsQuery, useCreateProviderMutation, getErrorMessage } from "@/lib/store";
// Get real-time logs with automatic caching
const { data: logs, error, isLoading } = useGetLogsQuery({ filters, pagination });
// Configure provider with optimistic updates
const [createProvider] = useCreateProviderMutation();
const handleCreate = async () => {
try {
await createProvider({
provider: "openai",
keys: [{ value: "sk-...", models: ["gpt-4"], weight: 1 }],
// ... other config
}).unwrap();
// Success handling
} catch (error) {
console.error(getErrorMessage(error));
}
};
```
### Component Guidelines
- **Composition**: Use Radix UI primitives for accessibility
- **Styling**: Tailwind CSS with CSS variables for theming
- **Types**: Full TypeScript coverage matching Go backend schemas
- **Error Handling**: Consistent error states and user feedback
### Adding New Features
1. **Backend Integration**: Add API endpoints to RTK Query slices in `lib/store/`
2. **Type Definitions**: Update types in `lib/types/`
3. **UI Components**: Build with Radix UI and Tailwind
4. **State Management**: Use RTK Query for API state, React hooks for local state
5. **Real-time Updates**: Integrate WebSocket events when applicable
## Configuration
### Provider Setup
The UI supports comprehensive provider configuration including API keys with model assignments, network settings, and provider-specific options.
**[Complete Provider Configuration Guide →](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)**
### Governance & Access Control
Configure virtual keys, budget limits, rate limiting, and team-based access control through the UI.
**[Governance Documentation →](https://docs.getbifrost.ai/features/governance)**
### Real-time Features
WebSocket connection provides live log streaming, connection status monitoring, automatic reconnection, and filtered real-time updates.
**[Observability Features →](https://docs.getbifrost.ai/features/observability)**
## Monitoring & Analytics
The dashboard provides comprehensive observability including request metrics, token usage tracking, provider performance analysis, error categorization, and historical trend analysis.
**[Performance Benchmarks →](https://docs.getbifrost.ai/benchmarking/getting-started)**
## Contributing
We welcome contributions! See our [Contributing Guide](https://docs.getbifrost.ai/contributing/setting-up-repo) for:
- Code conventions and style guide
- Development setup and workflow
- Adding new providers or features
- Plugin development guidelines
## Documentation
**Complete Documentation:** [https://docs.getbifrost.ai](https://docs.getbifrost.ai)
### Quick Links
- [Gateway Setup](https://docs.getbifrost.ai/quickstart/gateway/setting-up) - Get started in 30 seconds
- [Provider Configuration](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration) - Multi-provider setup
- [MCP Integration](https://docs.getbifrost.ai/features/mcp) - External tool calling
- [Plugin Development](https://docs.getbifrost.ai/plugins/getting-started) - Build custom plugins
- [Architecture](https://docs.getbifrost.ai/architecture) - System design and internals
## Need Help?
**[Join our Discord](https://discord.gg/exN5KAydbU)** for community support and discussions.
Get help with:
- Quick setup assistance and troubleshooting
- Best practices and configuration tips
- Community discussions and support
- Real-time help with integrations
## Links
- **Main Repository**: [github.com/maximhq/bifrost](https://github.com/maximhq/bifrost)
- **HTTP Transport**: [../transports/bifrost-http](../transports/bifrost-http)
- **Documentation**: [docs.getbifrost.ai](https://docs.getbifrost.ai)
- **Website**: [getbifrost.ai](https://www.getbifrost.ai)
## License
Licensed under the Apache 2.0 License - see the [LICENSE](../LICENSE) file for details.
---
Built with ❤️ by [Maxim](https://github.com/maximhq)

18
ui/app/__error.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Button } from "@/components/ui/button";
export function ErrorComponent() {
return (
<main className="h-base flex items-center justify-center p-6">
<div className="mx-auto w-full max-w-md text-center">
<p className="text-foreground text-7xl font-bold tracking-tight">500</p>
<h1 className="text-foreground mt-4 text-2xl font-semibold">Something went wrong</h1>
<p className="text-muted-foreground mt-2 text-sm">Something went wrong. Please refresh the page.</p>
<div className="mt-6 flex items-center justify-center gap-3">
<Button size={"sm"} data-testid="error-reload-btn" onClick={() => window.location.reload()}>
Reload
</Button>
</div>
</div>
</main>
);
}

22
ui/app/__notFound.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { Link } from "@tanstack/react-router";
export function NotFoundComponent() {
return (
<main className="h-base flex items-center justify-center p-6">
<div className="mx-auto w-full max-w-md text-center">
<p className="text-foreground text-7xl font-bold tracking-tight">404</p>
<h1 className="text-foreground mt-4 text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground mt-2 text-sm">The page you are looking for doesnt exist or has been moved</p>
<div className="mt-6 flex items-center justify-center gap-3">
<Link
data-testid="not-found-go-home-link"
to="/workspace/logs"
className="bg-primary text-primary-foreground focus-visible:ring-primary inline-flex items-center rounded-md px-4 py-2 text-sm font-medium shadow transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
Go home
</Link>
</div>
</div>
</main>
);
}

23
ui/app/__root.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Outlet, createRootRoute, redirect } from "@tanstack/react-router";
/**
* Root route. Intentionally minimal — does NOT wrap children in providers,
* because the existing nested layouts (app/workspace/layout.tsx →
* ClientLayout, app/login/layout.tsx, app/pprof/layout.tsx) each set up
* their own ThemeProvider / ReduxProvider / NuqsAdapter / etc.
*
* If/when we consolidate provider setup, the providers can move here.
*/
export const Route = createRootRoute({
beforeLoad: ({ location }) => {
// Root index "/" → /login. The old app/page.tsx redirect lived here.
if (location.pathname === "/" || location.pathname === "") {
throw redirect({ to: "/login" });
}
},
component: RootComponent,
});
function RootComponent() {
return <Outlet />;
}

View File

@@ -0,0 +1,17 @@
import { ShieldCheck } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function AccessProfilesIndexView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<ShieldCheck className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock access profiles for better performance"
description="This feature is a part of the Bifrost enterprise license. Create access profiles to control access to your resources."
readmeLink="https://docs.getbifrost.ai/enterprise/access-profiles"
testIdPrefix="access-profiles"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Shuffle } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function AdaptiveRoutingView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Shuffle className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock adaptive routing for better performance"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/adaptive-load-balancing"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Siren } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function AlertChannelsView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Siren className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock alert channels for better observability"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/alert-channels"
/>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useGetCoreConfigQuery } from "@/lib/store";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Link } from "@tanstack/react-router";
import { Copy, InfoIcon, KeyRound } from "lucide-react";
import { useMemo } from "react";
import ContactUsView from "../views/contactUsView";
export default function APIKeysView() {
const { data: bifrostConfig, isLoading } = useGetCoreConfigQuery({ fromDB: true });
const isAuthConfigure = useMemo(() => {
return bifrostConfig?.auth_config?.is_enabled;
}, [bifrostConfig]);
const curlExample = `# Base64 encode your username:password
# Example: echo -n "username:password" | base64
curl --location 'http://localhost:8080/v1/chat/completions'
--header 'Content-Type: application/json'
--header 'Accept: application/json'
--header 'Authorization: Basic <base64_encoded_username:password>'
--data '{
"model": "openai/gpt-4",
"messages": [
{
"role": "user",
"content": "explain big bang?"
}
]
}'`;
const { copy: copyToClipboard } = useCopyToClipboard();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthConfigure) {
return (
<Alert variant="default">
<InfoIcon className="text-muted h-4 w-4" />
<AlertDescription>
<p className="text-md text-muted-foreground">
To generate API keys, you need to set up admin username and password first.{" "}
<Link to="/workspace/config/security" className="text-md text-primary underline">
Configure Security Settings
</Link>
.<br />
<br />
Once generated you will need to use this API key for all API calls to the Bifrost admin APIs and UI.
</p>
</AlertDescription>
</Alert>
);
}
const isInferenceAuthDisabled = bifrostConfig?.auth_config?.disable_auth_on_inference ?? false;
return (
<div className="mx-auto w-full max-w-4xl space-y-4">
<Alert variant="default">
<InfoIcon className="text-muted h-4 w-4" />
<AlertDescription>
<p className="text-md text-muted-foreground">
{isInferenceAuthDisabled ? (
<>
Authentication is currently <strong>disabled for inference API calls</strong>. You can make inference requests without
authentication. Dashboard and admin API calls still require Basic auth with your admin credentials encoded in the standard{" "}
<code className="bg-muted rounded px-1 py-0.5 text-sm">username:password</code> format with base64 encoding.
</>
) : (
<>
Use Basic auth with your admin credentials when making API calls to Bifrost. Encode your credentials in the standard{" "}
<code className="bg-muted rounded px-1 py-0.5 text-sm">username:password</code> format with base64 encoding.
</>
)}
</p>
{!isInferenceAuthDisabled && (
<>
<br />
<p className="text-md text-muted-foreground">
<strong>Example:</strong>
</p>
<div className="relative mt-2 w-full min-w-0 overflow-x-auto">
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(curlExample)} className="absolute top-2 right-2 z-10 h-8">
<Copy className="h-4 w-4" />
</Button>
<pre className="bg-muted min-w-max rounded p-3 pr-12 font-mono text-sm whitespace-pre">{curlExample}</pre>
</div>
</>
)}
</AlertDescription>
</Alert>
<ContactUsView
className="mt-4 rounded-md border px-3 py-8"
icon={<KeyRound size={48} />}
title="Scope Based API Keys"
description="Need granular access control with scope-based API keys? Enterprise customers can create multiple API keys with specific permissions for different services, teams, or environments."
readmeLink="https://docs.getbifrost.io/enterprise/api-keys"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { ScrollText } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function AuditLogsView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<ScrollText className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock audit logs for better compliance"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/audit-logs"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Layers } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function ClusterPage() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Layers className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock cluster mode to scale reliably"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/clustering"
/>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Database } from "lucide-react";
import ContactUsView from "../../views/contactUsView";
interface EnableToggleProps {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
}
interface BigQueryConnectorViewProps {
onDelete?: () => void;
isDeleting?: boolean;
enableToggle?: EnableToggleProps;
}
export default function BigQueryConnectorView(_props: BigQueryConnectorViewProps) {
return (
<div className="space-y-6">
{/* Content - OSS: paywall only; no delete/save buttons */}
<div className="space-y-4">
<div className="flex w-full flex-col items-center justify-center py-8">
<ContactUsView
align="middle"
className="mx-auto w-full max-w-lg"
icon={<Database className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock native BigQuery data ingestion for analytics"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/bigquery-connector"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Dog } from "lucide-react";
import ContactUsView from "../../views/contactUsView";
interface EnableToggleProps {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
}
interface DatadogConnectorViewProps {
onDelete?: () => void;
isDeleting?: boolean;
enableToggle?: EnableToggleProps;
}
export default function DatadogConnectorView(_props: DatadogConnectorViewProps) {
return (
<div className="space-y-6">
{/* Content - OSS: paywall only; no delete/save buttons */}
<div className="space-y-4">
<div className="flex w-full flex-col items-center justify-center py-8">
<ContactUsView
align="middle"
className="mx-auto w-full max-w-lg"
icon={<Dog className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock native Datadog data ingestion for better observability"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/datadog-connector"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Construction } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function GuardrailsConfigurationView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Construction className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock guardrails for better security"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/guardrails"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Construction } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function guardrailsProviderView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Construction className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock guardrails for better security"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/guardrails"
/>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { LargePayloadConfig } from "@enterprise/lib/types/largePayload";
export interface LargePayloadSettingsFragmentProps {
config: LargePayloadConfig;
onConfigChange: (config: LargePayloadConfig) => void;
controlsDisabled: boolean;
}
export default function LargePayloadSettingsFragment(_props: LargePayloadSettingsFragmentProps) {
return null;
}

View File

@@ -0,0 +1,186 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage, useIsAuthEnabledQuery, useLoginMutation } from "@/lib/store/apis";
import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
const externalLinks = [
{
title: "Discord Server",
url: "https://discord.gg/exN5KAydbU",
icon: DiscordLogoIcon,
},
{
title: "GitHub Repository",
url: "https://github.com/maximhq/bifrost",
icon: GithubLogoIcon,
},
{
title: "Full Documentation",
url: "https://docs.getbifrost.ai",
icon: BooksIcon,
strokeWidth: 1,
},
];
export default function LoginView() {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { data: isAuthEnabledData, isLoading: isLoadingIsAuthEnabled, error: isAuthEnabledError } = useIsAuthEnabledQuery();
const isAuthEnabled = isAuthEnabledData?.is_auth_enabled || false;
const hasValidToken = isAuthEnabledData?.has_valid_token || false;
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
useEffect(() => {
setMounted(true);
}, []);
// Check auth status on component mount
useEffect(() => {
if (isLoadingIsAuthEnabled) {
return;
}
if (isAuthEnabledError) {
setErrorMessage("Unable to verify authentication status. Please retry.");
return;
}
if (!isAuthEnabled || hasValidToken) {
navigate({ to: "/workspace" });
return;
}
// Auth is enabled but user is not logged in, show login form
setIsCheckingAuth(false);
}, [isLoadingIsAuthEnabled]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsLoading(true);
e.preventDefault();
setErrorMessage("");
try {
await login({ username, password }).unwrap();
// Cookie is set automatically by the server response — just navigate
navigate({ to: "/workspace" });
} catch (error) {
const message = getErrorMessage(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
// Use light logo for SSR to avoid hydration mismatch
const logoSrc = mounted && resolvedTheme === "dark" ? "/bifrost-logo-dark.webp" : "/bifrost-logo.webp";
// Show loading state while checking auth
if (isCheckingAuth || isLoadingIsAuthEnabled) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="border-border bg-card w-full space-y-6 rounded-sm border p-8">
<div className="flex items-center justify-center">
<img src={logoSrc} alt="Bifrost" width={160} height={26} className="" />
</div>
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground text-sm">Checking authentication...</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="border-border bg-card w-full space-y-6 rounded-sm border p-8">
{/* Logo */}
<div className="flex items-center justify-center">
<img src={logoSrc} alt="Bifrost" width={160} height={26} className="" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-foreground text-lg font-semibold">Welcome back</h1>
<p className="text-muted-foreground text-sm">Sign in to your account to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{errorMessage && <div className="bg-destructive/10 text-destructive rounded-sm p-3 text-sm">{errorMessage}</div>}
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-medium">
Username
</Label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="text-sm"
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pr-10 text-sm"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button type="submit" className="h-9 w-full text-sm" isLoading={isLoading} disabled={isLoading}>
{isLoading || isLoggingIn ? "Signing in..." : "Sign in"}
</Button>
</form>
{/* Social Links */}
<div className="flex items-center justify-center gap-4 pt-4">
{externalLinks.map((item, index) => (
<a
key={index}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
title={item.title}
>
<item.icon className="h-5 w-5" size={20} weight="regular" strokeWidth={item.strokeWidth} />
</a>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { ShieldUser } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function MCPAuthConfigView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<ShieldUser className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock MCP Auth Config"
description="This feature is a part of the Bifrost enterprise license. Configure authentication for MCP servers to secure your MCP connections."
readmeLink="https://docs.getbifrost.ai/mcp/overview"
/>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { ToolCase } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function MCPToolGroups() {
return (
<>
<div className="flex items-center justify-between gap-4 mb-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">MCP tool groups</h2>
<p className="text-muted-foreground text-sm">Configure tool groups for MCP servers to organize and govern tools.</p>
</div>
</div>
<div className="rounded-sm border">
<div className="flex w-full flex-col items-center justify-center py-16">
<ContactUsView
className="mx-auto w-full max-w-lg"
icon={<ToolCase className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock MCP Tool Groups"
description="This feature is a part of the Bifrost enterprise license. Configure tool groups for MCP servers to organize your MCP tools and govern them across your organization."
readmeLink="https://docs.getbifrost.ai/mcp/overview"
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,16 @@
import { ScanEye } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function PiiRedactorProviderView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<ScanEye className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock PII Redaction for better privacy"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/pii-redactor"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { ScanEye } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function PiiRedactorRulesView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<ScanEye className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock PII Redaction for better privacy"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/pii-redactor"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Router } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function PromptDeploymentView(_props?: { omitTitle?: boolean }) {
return (
<div className="w-full">
<ContactUsView
align="top"
className="justify-start gap-3 rounded-md border p-4"
icon={<Router className="h-8 w-8" strokeWidth={1.5} />}
title="Unlock prompt deployments for better prompt versioning and A/B testing."
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/prompt-deployments"
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { usePromptContext } from "@/components/prompts/context";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import PromptDeploymentView from "./promptDeploymentView";
export type SettingsSidebarSection = "parameters" | "deployments";
export function PromptDeploymentsAccordionItem({ activeSection }: { activeSection: SettingsSidebarSection | undefined }) {
const { selectedPromptId } = usePromptContext();
if (!selectedPromptId) {
return null;
}
const deploymentsOpen = activeSection === "deployments";
return (
<AccordionItem
value="deployments"
className={cn(
"border-border/60 flex min-h-0 flex-col border-b-0 border-t pt-1",
deploymentsOpen ? "min-h-0 grow overflow-hidden" : "shrink-0 grow-0",
)}
>
<AccordionTrigger
data-testid="prompt-deployments-trigger"
className="text-muted-foreground w-full min-w-0 shrink-0 py-3 pr-1 text-xs font-medium uppercase hover:no-underline [&[data-state=open]>svg]:rotate-180"
>
<span className="min-w-0 flex-1 text-left font-semibold">Deployments</span>
</AccordionTrigger>
<AccordionContent
containerClassName="data-[state=open]:flex data-[state=open]:min-h-0 data-[state=open]:flex-1 data-[state=open]:flex-col"
className="min-h-0 flex-1 overflow-y-auto pt-0 pb-2"
>
<PromptDeploymentView omitTitle />
</AccordionContent>
</AccordionItem>
);
}

View File

@@ -0,0 +1,16 @@
import { UserRoundCheck } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function RBACView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<UserRoundCheck className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock roles and permissions for better security"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { BookUser } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function SCIMView() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<BookUser className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock SCIM based access management for user provisioning"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Building2 } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export function BusinessUnitsView() {
return (
<div className="w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
testIdPrefix="business-units-governance"
icon={<Building2 className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock business units & advanced governance"
description="Manage users, business units with our enterprise-grade governance. This feature is part of the Bifrost enterprise license."
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import TeamsTable from "@/app/workspace/governance/views/teamsTable";
import FullPageLoader from "@/components/fullPageLoader";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export function TeamsView() {
const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
const shownErrorsRef = useRef(new Set<string>());
const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const debouncedSearch = useDebouncedValue(search, 300);
useEffect(() => {
setOffset(0);
}, [debouncedSearch]);
const {
data: virtualKeysData,
error: vkError,
isLoading: vkLoading,
} = useGetVirtualKeysQuery(undefined, {
skip: !hasVirtualKeysAccess,
pollingInterval: POLLING_INTERVAL,
});
const {
data: customersData,
error: customersError,
isLoading: customersLoading,
} = useGetCustomersQuery(undefined, {
skip: !hasCustomersAccess,
pollingInterval: POLLING_INTERVAL,
});
const {
data: teamsData,
error: teamsError,
isLoading: teamsLoading,
} = useGetTeamsQuery(
{
limit: PAGE_SIZE,
offset,
search: debouncedSearch || undefined,
},
{
skip: !hasTeamsAccess,
pollingInterval: POLLING_INTERVAL,
},
);
const teamsTotal = teamsData?.total_count ?? 0;
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
useEffect(() => {
if (!teamsData || offset < teamsTotal) return;
setOffset(teamsTotal === 0 ? 0 : Math.floor((teamsTotal - 1) / PAGE_SIZE) * PAGE_SIZE);
}, [teamsTotal, offset]);
const isLoading = vkLoading || customersLoading || teamsLoading;
useEffect(() => {
if (!vkError && !customersError && !teamsError) {
shownErrorsRef.current.clear();
return;
}
const errorKey = `${!!vkError}-${!!customersError}-${!!teamsError}`;
if (shownErrorsRef.current.has(errorKey)) return;
shownErrorsRef.current.add(errorKey);
if (vkError && customersError && teamsError) {
toast.error("Failed to load governance data.");
} else {
if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
if (customersError) toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
}
}, [vkError, customersError, teamsError]);
if (isLoading) {
return <FullPageLoader />;
}
return (
<div className="mx-auto w-full max-w-7xl">
<TeamsTable
teams={teamsData?.teams || []}
totalCount={teamsData?.total_count || 0}
customers={customersData?.customers || []}
virtualKeys={virtualKeysData?.virtual_keys || []}
search={search}
debouncedSearch={debouncedSearch}
onSearchChange={setSearch}
offset={offset}
limit={PAGE_SIZE}
onOffsetChange={setOffset}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Users } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function UsersView() {
return (
<div className="w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Users className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock users & user governance"
description="Manage users, set per-user budgets and rate limits, and control access with enterprise-grade governance. This feature is part of the Bifrost enterprise license."
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Users } from "lucide-react";
import ContactUsView from "../views/contactUsView";
export default function UserRankingsTab() {
return (
<div className="h-full w-full">
<ContactUsView
className="mx-auto min-h-[80vh]"
icon={<Users className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
title="Unlock user rankings for better visibility"
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
readmeLink="https://docs.getbifrost.ai/enterprise/user-rankings"
testIdPrefix="user-rankings"
/>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowUpRight } from "lucide-react";
interface Props {
className?: string;
icon: React.ReactNode;
title: string;
description: string;
readmeLink: string;
align?: "middle" | "top";
testIdPrefix?: string;
}
export default function ContactUsView({ icon, title, description, className, readmeLink, align = "middle", testIdPrefix }: Props) {
return (
<div className={cn("flex flex-col items-center gap-4 text-center", align === "middle" ? "justify-center" : "justify-start", className)}>
<div className="text-muted-foreground">{icon}</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">{title}</h1>
<div className="text-muted-foreground mt-2 max-w-[600px] text-sm font-normal">{description}</div>
<div className="mx-auto flex flex-row items-center gap-2">
<Button
variant="outline"
aria-label="Read more about this feature (opens in new tab)"
className="mx-auto mt-6"
data-testid={testIdPrefix ? `${testIdPrefix}-read-more` : undefined}
onClick={() => {
window.open(`${readmeLink}?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
<Button
className="mx-auto mt-6"
aria-label="Book a demo (opens Calendly in new tab)"
data-testid={testIdPrefix ? `${testIdPrefix}-book-demo` : undefined}
onClick={() => {
window.open("https://calendly.com/maximai/bifrost-demo?utm_source=bfd_ent", "_blank", "noopener,noreferrer");
}}
>
Book a demo
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { createContext, useContext } from "react";
// RBAC Resource Names (must match backend definitions)
export enum RbacResource {
GuardrailsConfig = "GuardrailsConfig",
GuardrailsProviders = "GuardrailsProviders",
GuardrailRules = "GuardrailRules",
UserProvisioning = "UserProvisioning",
Cluster = "Cluster",
Settings = "Settings",
Users = "Users",
Logs = "Logs",
Observability = "Observability",
VirtualKeys = "VirtualKeys",
ModelProvider = "ModelProvider",
Plugins = "Plugins",
MCPGateway = "MCPGateway",
AdaptiveRouter = "AdaptiveRouter",
AuditLogs = "AuditLogs",
Customers = "Customers",
Teams = "Teams",
RBAC = "RBAC",
Governance = "Governance",
RoutingRules = "RoutingRules",
PIIRedactor = "PIIRedactor",
PromptRepository = "PromptRepository",
PromptDeploymentStrategy = "PromptDeploymentStrategy",
AccessProfiles = "AccessProfiles",
}
// RBAC Operation Names (must match backend definitions)
export enum RbacOperation {
Read = "Read",
View = "View",
Create = "Create",
Update = "Update",
Delete = "Delete",
Download = "Download",
}
interface RbacContextType {
isAllowed: (resource: RbacResource, operation: RbacOperation) => boolean;
permissions: Record<string, Record<string, boolean>>;
isLoading: boolean;
refetch: () => void;
}
const RbacContext = createContext<RbacContextType | null>(null);
// Dummy provider that allows all permissions
export function RbacProvider({ children }: { children: React.ReactNode }) {
return (
<RbacContext.Provider
value={{
isAllowed: () => true, // Always allow in OSS
permissions: {},
isLoading: false,
refetch: () => {},
}}
>
{children}
</RbacContext.Provider>
);
}
// Hook that always returns true (no restrictions in OSS)
export function useRbac(_resource: RbacResource, _operation: RbacOperation): boolean {
return true;
}
// Hook to access full RBAC context
export function useRbacContext() {
const context = useContext(RbacContext);
if (!context) {
// Return dummy values if used outside provider
return {
isAllowed: () => true,
permissions: {},
isLoading: false,
refetch: () => {},
};
}
return context;
}

View File

@@ -0,0 +1,25 @@
// Fallback exports for non-enterprise builds
export * from "./store";
// Re-export OAuth token management utilities for convenience (fallback no-ops)
export {
REFRESH_TOKEN_ENDPOINT,
clearOAuthStorage,
clearUserInfo,
getAccessToken,
getRefreshState,
getRefreshToken,
getTokenExpiry,
getUserInfo,
isTokenExpired,
setOAuthTokens,
setRefreshState,
setUserInfo,
type UserInfo,
} from "./store/utils/tokenManager";
// Re-export base query (fallback passthrough)
export { createBaseQueryWithRefresh } from "./store/utils/baseQueryWithRefresh";
// Re-export RBAC context (dummy implementation for OSS)
export * from "./contexts/rbacContext";

View File

@@ -0,0 +1,18 @@
import { GetUserAccessProfilesResponse } from "@enterprise/lib/types/accessProfile";
// OSS build has no access-profile backend — return undefined data so consumers
// (e.g. useVirtualKeyUsage) fall back to VK-owned budget/rate-limit values.
export const useGetUserAccessProfilesQuery = (
_userId: string,
_opts?: { skip?: boolean; pollingInterval?: number },
): {
data: GetUserAccessProfilesResponse | undefined;
isLoading: boolean;
isError: boolean;
error: null;
} => ({
data: undefined,
isLoading: false,
isError: false,
error: null,
});

View File

@@ -0,0 +1,11 @@
// Placeholder for enterprise APIs
// Export empty objects when enterprise features are not available
export const scimApi = null;
export const guardrailsApi = null;
export const clusterApi = null;
export const rbacApi = null;
export const auditLogsApi = null;
// Empty apis array when enterprise features are not available
export const apis = [];

View File

@@ -0,0 +1,18 @@
import { LargePayloadConfig } from "@enterprise/lib/types/largePayload";
export const useGetLargePayloadConfigQuery = (): {
data: LargePayloadConfig | undefined;
isLoading: boolean;
isError: boolean;
error: null;
} => ({
data: undefined,
isLoading: false,
isError: false,
error: null,
});
export const useUpdateLargePayloadConfigMutation = (): [
(_config: LargePayloadConfig) => { unwrap: () => Promise<void> },
{ isLoading: boolean },
] => [() => ({ unwrap: async () => {} }), { isLoading: false }];

View File

@@ -0,0 +1,22 @@
import { User } from "@enterprise/lib/types/user";
export interface GetVirtualKeyUsersResponse {
users: User[];
}
// OSS build has no VK-user-attachment backend — return undefined data so the
// consumer treats the VK as unassigned (no AP-managed detection happens).
export const useGetVirtualKeyUsersQuery = (
_vkId: string,
_opts?: { skip?: boolean },
): {
data: GetVirtualKeyUsersResponse | undefined;
isLoading: boolean;
isError: boolean;
error: null;
} => ({
data: undefined,
isLoading: false,
isError: false,
error: null,
});

View File

@@ -0,0 +1,23 @@
// Fallback exports for non-enterprise builds
export * from "./apis";
export * from "./slices";
// Export OAuth token management utilities (fallback no-ops)
export {
REFRESH_TOKEN_ENDPOINT,
clearOAuthStorage,
clearUserInfo,
getAccessToken,
getRefreshState,
getRefreshToken,
getTokenExpiry,
getUserInfo,
isTokenExpired,
setOAuthTokens,
setRefreshState,
setUserInfo,
type UserInfo,
} from "./utils/tokenManager";
// Export base query (fallback passthrough)
export { createBaseQueryWithRefresh } from "./utils/baseQueryWithRefresh";

View File

@@ -0,0 +1,12 @@
// Placeholder for enterprise reducers
// Export noop reducers when enterprise features are not available
export const scimReducer = (state = {}) => state;
export const userReducer = (state = {}) => state;
export const guardrailReducer = (state = {}) => state;
// Empty reducers map when enterprise features are not available
export const reducers = {};
// Empty enterprise state type when enterprise features are not available
export type EnterpriseState = {};

View File

@@ -0,0 +1,13 @@
// Fallback base query for non-enterprise builds
// Simply passes through the base query without any refresh logic
import type { BaseQueryFn } from "@reduxjs/toolkit/query/react";
/**
* Fallback base query wrapper that does nothing
* Used when enterprise features are not available
*/
export function createBaseQueryWithRefresh(baseQuery: BaseQueryFn): BaseQueryFn {
// Simply return the base query as-is (no refresh logic)
return baseQuery;
}

View File

@@ -0,0 +1,77 @@
// Fallback OAuth Token Manager for non-enterprise builds
// These functions return null/no-op when enterprise features are not available
export const getAccessToken = async (): Promise<string | null> => Promise.resolve(null);
export const getRefreshToken = async (): Promise<string | null> => Promise.resolve(null);
export const getTokenExpiry = (): number | null => null;
export const isTokenExpired = (): boolean => false;
export const setOAuthTokens = async (_accessToken: string, _expiresIn?: number | null) => {
// No-op in non-enterprise builds
};
export const clearOAuthStorage = () => {
// No-op in non-enterprise builds
};
export const getRefreshState = () => ({
isRefreshing: false,
refreshPromise: null,
});
export const setRefreshState = (_refreshing: boolean, _promise: Promise<any> | null = null) => {
// No-op in non-enterprise builds
};
export const REFRESH_TOKEN_ENDPOINT = "";
// User info type definition (matching enterprise version)
export interface UserInfo {
name?: string;
email?: string;
picture?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
}
// Fallback getUserInfo that returns null for non-enterprise builds
export const getUserInfo = (): UserInfo | null => null;
// Fallback setUserInfo - no-op
export const setUserInfo = (_userInfo: UserInfo) => {
// No-op in non-enterprise builds
};
// Fallback clearUserInfo - no-op
export const clearUserInfo = () => {
// No-op in non-enterprise builds
};
// Fallback secure storage functions - no-op
export const setSecureItem = async (key: string, value: string): Promise<void> => {
// No-op in non-enterprise builds
};
export const getSecureItem = async (key: string): Promise<string | null> => Promise.resolve(null);
export const removeSecureItem = (key: string): void => {
// No-op in non-enterprise builds
};
export const setSecureLocalItem = async (key: string, value: string): Promise<void> => {
// No-op in non-enterprise builds
};
export const getSecureLocalItem = async (key: string): Promise<string | null> => Promise.resolve(null);
export const removeSecureLocalItem = (key: string): void => {
// No-op in non-enterprise builds
};
export const clearEncryptionKey = (): void => {
// No-op in non-enterprise builds
};

View File

@@ -0,0 +1,41 @@
export interface AccessProfileBudgetLine {
id: string;
scope: string;
max_limit: number;
reset_duration: string;
current_usage: number;
last_reset: string;
alert_thresholds?: number[];
}
export interface AccessProfileRateLimitLine {
token_max_limit?: number;
token_reset_duration?: string;
token_current_usage?: number;
token_last_reset?: string;
request_max_limit?: number;
request_reset_duration?: string;
request_current_usage?: number;
request_last_reset?: string;
}
export interface UserAccessProfile {
id: number;
user_id: string;
parent_profile_id?: number;
virtual_key_ids?: string[];
virtual_key_values?: Record<string, string>;
name: string;
is_active: boolean;
expires_at?: string;
provider_configs?: unknown[];
budgets?: AccessProfileBudgetLine[];
rate_limit?: AccessProfileRateLimitLine;
mcp_configs?: unknown;
created_at: string;
updated_at: string;
}
export interface GetUserAccessProfilesResponse {
access_profiles: UserAccessProfile[];
}

View File

@@ -0,0 +1,17 @@
export interface LargePayloadConfig {
enabled: boolean;
request_threshold_bytes: number;
response_threshold_bytes: number;
prefetch_size_bytes: number;
max_payload_bytes: number;
truncated_log_bytes: number;
}
export const DefaultLargePayloadConfig: LargePayloadConfig = {
enabled: false,
request_threshold_bytes: 10 * 1024 * 1024, // 10MB
response_threshold_bytes: 10 * 1024 * 1024, // 10MB
prefetch_size_bytes: 64 * 1024, // 64KB
max_payload_bytes: 500 * 1024 * 1024, // 500MB
truncated_log_bytes: 1024 * 1024, // 1MB
};

View File

@@ -0,0 +1,30 @@
import { UserAccessProfile } from "@enterprise/lib/types/accessProfile";
export interface User {
id: string;
name: string;
email: string;
role_id?: number;
role?: {
id: number;
name: string;
description: string;
is_system_role: boolean;
};
profile?: Record<string, unknown>;
config?: Record<string, unknown>;
claims?: Record<string, unknown>;
access_profile?: UserAccessProfile;
teams?: Array<{ id: string; name: string; business_unit_id?: string; business_unit_name?: string }>;
created_at: string;
updated_at: string;
}
export interface GetUsersResponse {
users: User[];
total: number;
page: number;
limit: number;
total_pages: number;
has_more: boolean;
}

84
ui/app/clientLayout.tsx Normal file
View File

@@ -0,0 +1,84 @@
import FullPageLoader from "@/components/fullPageLoader";
import NotAvailableBanner from "@/components/notAvailableBanner";
import ProgressProvider from "@/components/progressBar";
import Sidebar from "@/components/sidebar";
import { ThemeProvider } from "@/components/themeProvider";
import { SidebarProvider } from "@/components/ui/sidebar";
import { useStoreSync } from "@/hooks/useStoreSync";
import { WebSocketProvider } from "@/hooks/useWebSocket";
import { getErrorMessage, ReduxProvider, useGetCoreConfigQuery } from "@/lib/store";
import { BifrostConfig } from "@/lib/types/config";
import { RbacProvider } from "@enterprise/lib/contexts/rbacContext";
import { useLocation } from "@tanstack/react-router";
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
import { Suspense, lazy, useEffect } from "react";
import { CookiesProvider } from "react-cookie";
import { toast, Toaster } from "sonner";
// Lazy import — only loaded in development, completely excluded from prod bundle
const DevProfilerLazy = lazy(() => import("@/components/devProfiler").then((mod) => ({ default: mod.DevProfiler })));
const DevProfiler = () => (
<Suspense fallback={null}>
<DevProfilerLazy />
</Suspense>
);
function StoreSyncInitializer() {
useStoreSync();
return null;
}
function AppContent({ children }: { children: React.ReactNode }) {
const { data: bifrostConfig, error, isLoading } = useGetCoreConfigQuery({});
useEffect(() => {
if (error) {
toast.error(getErrorMessage(error));
}
}, [error]);
return (
<WebSocketProvider>
<CookiesProvider>
<StoreSyncInitializer />
<SidebarProvider>
<Sidebar />
<div className="dark:bg-card custom-scrollbar content-container my-[0.5rem] mr-[0.5rem] h-[calc(100dvh-1rem)] w-full min-w-xl overflow-auto rounded-md border border-gray-200 bg-white px-10 dark:border-zinc-800">
<main className="custom-scrollbar content-container-inner relative mx-auto flex flex-col overflow-y-hidden p-4">
{isLoading ? <FullPageLoader /> : <FullPage config={bifrostConfig}>{children}</FullPage>}
</main>
</div>
</SidebarProvider>
</CookiesProvider>
</WebSocketProvider>
);
}
function FullPage({ config, children }: { config: BifrostConfig | undefined; children: React.ReactNode }) {
const pathname = useLocation({ select: (l) => l.pathname });
if (config && config.is_db_connected) {
return children;
}
if (config && config.is_logs_connected && pathname.startsWith("/workspace/logs")) {
return children;
}
return <NotAvailableBanner />;
}
export function ClientLayout({ children }: { children: React.ReactNode }) {
return (
<ProgressProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Toaster />
<ReduxProvider>
<NuqsAdapter>
<RbacProvider>
<AppContent>{children}</AppContent>
{process.env.NODE_ENV === "development" && !process.env.BIFROST_DISABLE_PROFILER && <DevProfiler />}
</RbacProvider>
</NuqsAdapter>
</ReduxProvider>
</ThemeProvider>
</ProgressProvider>
);
}

366
ui/app/globals.css Normal file
View File

@@ -0,0 +1,366 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../app/**/*.tsx";
@source "../node_modules/streamdown/dist/*.js";
@source "../../../bifrost-enterprise/ui/**/*.tsx";
@custom-variant dark (&:is(.dark *));
/* ─── Local Geist fonts ───────────────────────────────────────────────────
* Variable woff2 files self-hosted from public/static/fonts/.
* Self-hosted variable woff2 files, replaces @fontsource-variable/geist.
*/
@font-face {
font-family: Geist;
src:
url("/static/fonts/Geist-Variable.woff2") format("woff2-variations"),
url("/static/fonts/Geist-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Geist;
src:
url("/static/fonts/Geist-Italic-Variable.woff2") format("woff2-variations"),
url("/static/fonts/Geist-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src:
url("/static/fonts/GeistMono-Variable.woff2") format("woff2-variations"),
url("/static/fonts/GeistMono-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src:
url("/static/fonts/GeistMono-Italic-Variable.woff2") format("woff2-variations"),
url("/static/fonts/GeistMono-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
:root {
--font-geist-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Roboto", sans-serif;
--font-geist-mono: "Geist Mono", ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Liberation Mono", monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--height-base: calc(100vh - 130px);
/* Font size overrides - format: [size, { line-height: value }] */
--text-xs: 0.75rem;
--text-xs--line-height: 1rem;
--text-sm: 0.825rem;
--text-sm--line-height: 1.25rem;
--text-base: 0.95rem;
--text-base--line-height: 1.5rem;
--text-lg: 1.125rem;
--text-lg--line-height: 1.75rem;
--text-xl: 1.25rem;
--text-xl--line-height: 1.75rem;
--text-2xl: 1.5rem;
--text-2xl--line-height: 2rem;
--text-3xl: 1.875rem;
--text-3xl--line-height: 2.25rem;
--text-4xl: 2.25rem;
--text-4xl--line-height: 2.5rem;
--text-5xl: 3rem;
--text-5xl--line-height: 1;
--text-6xl: 3.75rem;
--text-6xl--line-height: 1;
--text-7xl: 4.5rem;
--text-7xl--line-height: 1;
--text-8xl: 6rem;
--text-8xl--line-height: 1;
--text-9xl: 8rem;
--text-9xl--line-height: 1;
}
:root {
--radius: 0.5rem;
--color-cream-100: oklch(0.98 0 0);
--background: #f4f4f5;
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.5081 0.1049 165.61);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: color-mix(in oklch, var(--color-cream-100) 20%, transparent);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.94 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--color-ink-900: oklch(0.141 0.005 285.823);
--background: color-mix(in oklch, var(--color-ink-900) 20%, transparent);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: color-mix(in oklch, var(--color-ink-900) 20%, transparent);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-none;
}
body {
@apply bg-background text-foreground;
}
}
@utility custom-scrollbar {
overflow: auto !important;
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(228, 228, 231, 1) transparent; /* Firefox */
&::-webkit-scrollbar {
--custom-scrollbar-width: 8px;
--custom-scrollbar-height: 8px;
width: var(--custom-scrollbar-width, 8px);
height: var(--custom-scrollbar-height, 8px);
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
--tw-bg-opacity: 1 !important;
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
border-radius: 8px;
opacity: 0;
visibility: hidden;
}
&:hover::-webkit-scrollbar-thumb {
opacity: 1;
visibility: visible;
}
&::-webkit-scrollbar-thumb:hover {
--tw-bg-opacity: 1 !important;
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
}
/* For older WebKit browsers */
&::-webkit-scrollbar-thumb:horizontal {
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
}
&::-webkit-scrollbar-thumb:vertical {
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
}
&:hover::-webkit-scrollbar-thumb:horizontal {
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
}
&:hover::-webkit-scrollbar-thumb:vertical {
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
}
}
body {
overscroll-behavior: none;
}
.query-builder-wrapper {
padding: 1rem;
padding-inline: 0.5rem;
}
[data-radix-scroll-area-viewport].no-table > div {
display: block !important;
}
[data-radix-scroll-area-viewport].viewport-table-height-full > div {
height: 100% !important;
}
div.content-container:has(.no-padding-parent) {
@apply p-0!;
}
div.content-container main.content-container-inner:has(.no-padding-parent) {
@apply p-0!;
}
div.content-container:has(.no-border-parent) {
@apply border-0!;
}
/* ReactFlow Controls — follow Bifrost colour schema */
.react-flow__controls {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
.react-flow__controls-button {
background-color: var(--card);
border-bottom: 1px solid var(--border);
fill: var(--foreground);
}
.react-flow__controls-button:hover {
background-color: var(--muted);
}
.react-flow__controls-button svg {
fill: var(--foreground);
}
/* Dark mode — needs !important to beat ReactFlow's bundled specificity */
.dark .react-flow__controls {
background-color: var(--card) !important;
border-color: var(--border) !important;
}
.dark .react-flow__controls-button {
background-color: var(--card) !important;
border-bottom-color: var(--border) !important;
fill: var(--foreground) !important;
}
.dark .react-flow__controls-button:hover {
background-color: var(--muted) !important;
}
.dark .react-flow__controls-button svg {
fill: var(--foreground) !important;
}
/* Dynamic chain: dash period 3+5 = 8 — offset must move exactly one period per loop */
@keyframes rf-routing-tree-dynamic-chain-dash {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -8;
}
}
.rf-chain-legend-dynamic-dash {
animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite;
}
.react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path {
stroke-dasharray: 3 5;
animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.rf-chain-legend-dynamic-dash {
animation: none;
}
.react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path {
animation: none;
}
}
/* // Custom styling for streamdown */
[data-streamdown="code-block"],
[data-streamdown="code-block-body"] {
@apply rounded-sm!;
}

23
ui/app/login/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { ThemeProvider } from "@/components/themeProvider";
import { ReduxProvider } from "@/lib/store/provider";
import { createFileRoute } from "@tanstack/react-router";
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
import LoginPage from "./page";
function RouteComponent() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ReduxProvider>
<NuqsAdapter>
<div className="bg-background min-h-screen">
<LoginPage />
</div>
</NuqsAdapter>
</ReduxProvider>
</ThemeProvider>
);
}
export const Route = createFileRoute("/login")({
component: RouteComponent,
});

9
ui/app/login/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import LoginView from "@enterprise/components/login/loginView";
export default function LoginPage() {
return (
<div className="overflow-hidden">
<LoginView />
</div>
);
}

34
ui/app/main.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
// Tailwind + global styles (also declares @font-face for local Geist fonts).
import "@/app/globals.css";
import { routeTree } from "./routeTree.gen";
import { ErrorComponent } from "./__error";
import { NotFoundComponent } from "./__notFound";
const router = createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
notFoundMode: "root",
defaultNotFoundComponent: NotFoundComponent,
defaultErrorComponent: ErrorComponent,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Root element #root not found");
createRoot(rootEl).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

34
ui/app/pprof/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { ThemeProvider } from "@/components/themeProvider";
import { ReduxProvider } from "@/lib/store";
import { isDevelopmentMode } from "@/lib/utils/port";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Toaster } from "sonner";
import PprofPage from "./page";
function PprofLayout({ children }: { children: React.ReactNode }) {
// Only allow access in development mode
if (!isDevelopmentMode()) {
throw notFound();
}
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<Toaster />
<ReduxProvider>
<div className="min-h-screen bg-zinc-950 text-zinc-100">{children}</div>
</ReduxProvider>
</ThemeProvider>
);
}
function RouteComponent() {
return (
<PprofLayout>
<PprofPage />
</PprofLayout>
);
}
export const Route = createFileRoute("/pprof")({
component: RouteComponent,
});

1230
ui/app/pprof/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import AdaptiveRoutingPage from "./page";
function RouteComponent() {
const hasAdaptiveRouterAccess = useRbac(RbacResource.AdaptiveRouter, RbacOperation.View);
if (!hasAdaptiveRouterAccess) {
return <NoPermissionView entity="adaptive routing" />;
}
return <AdaptiveRoutingPage />;
}
export const Route = createFileRoute("/workspace/adaptive-routing")({
component: RouteComponent,
});

View File

@@ -0,0 +1,9 @@
import AdaptiveRoutingView from "@enterprise/components/adaptive-routing/adaptiveRoutingView";
export default function AdaptiveRoutingPage() {
return (
<div className="mx-auto w-full">
<AdaptiveRoutingView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AlertChannelsPage from "./page";
export const Route = createFileRoute("/workspace/alert-channels")({
component: AlertChannelsPage,
});

View File

@@ -0,0 +1,9 @@
import AlertChannelsView from "@enterprise/components/alert-channels/alertChannelsView";
export default function AlertChannelsPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<AlertChannelsView />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import AuditLogsPage from "./page";
function RouteComponent() {
const hasAuditLogsAccess = useRbac(RbacResource.AuditLogs, RbacOperation.View);
if (!hasAuditLogsAccess) {
return <NoPermissionView entity="audit logs" />;
}
return <AuditLogsPage />;
}
export const Route = createFileRoute("/workspace/audit-logs")({
component: RouteComponent,
});

View File

@@ -0,0 +1,9 @@
import AuditLogsView from "@enterprise/components/audit-logs/auditLogsView";
export default function AuditLogsPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<AuditLogsView />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import ClusterPage from "./page";
function RouteComponent() {
const hasClusterAccess = useRbac(RbacResource.Cluster, RbacOperation.View);
if (!hasClusterAccess) {
return <NoPermissionView entity="cluster configuration" />;
}
return <ClusterPage />;
}
export const Route = createFileRoute("/workspace/cluster")({
component: RouteComponent,
});

View File

@@ -0,0 +1,9 @@
import ClusterView from "@enterprise/components/cluster/clusterView";
export default function ClusterPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<ClusterView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import APIKeysPage from "./page";
export const Route = createFileRoute("/workspace/config/api-keys")({
component: APIKeysPage,
});

View File

@@ -0,0 +1,9 @@
import APIKeysView from "@enterprise/components/api-keys/apiKeysIndexView";
export default function APIKeysPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<APIKeysView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import CachingPage from "./page";
export const Route = createFileRoute("/workspace/config/caching")({
component: CachingPage,
});

View File

@@ -0,0 +1,9 @@
import CachingView from "../views/cachingView";
export default function CachingPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<CachingView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ClientSettingsPage from "./page";
export const Route = createFileRoute("/workspace/config/client-settings")({
component: ClientSettingsPage,
});

View File

@@ -0,0 +1,9 @@
import ClientSettingsView from "../views/clientSettingsView";
export default function ClientSettingsPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<ClientSettingsView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import CompatibilityPage from "./page";
export const Route = createFileRoute("/workspace/config/compatibility")({
component: CompatibilityPage,
});

View File

@@ -0,0 +1,9 @@
import CompatibilityView from "../views/compatibilityView";
export default function CompatibilityPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<CompatibilityView />
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/workspace/config/large-payload")({
beforeLoad: () => {
throw redirect({ to: "/workspace/config/client-settings" });
},
});

View File

@@ -0,0 +1,26 @@
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
import FullPageLoader from "@/components/fullPageLoader";
import { NoPermissionView } from "@/components/noPermissionView";
import { useGetCoreConfigQuery } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import ConfigPage from "./page";
function RouteComponent() {
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
const { isLoading } = useGetCoreConfigQuery({ fromDB: true }, { skip: !hasConfigAccess });
const childMatches = useChildMatches();
if (!hasConfigAccess) {
return <NoPermissionView entity="configuration" />;
}
if (isLoading) {
return <FullPageLoader />;
}
return childMatches.length === 0 ? <ConfigPage /> : <Outlet />;
}
export const Route = createFileRoute("/workspace/config")({
component: RouteComponent,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import LoggingPage from "./page";
export const Route = createFileRoute("/workspace/config/logging")({
component: LoggingPage,
});

View File

@@ -0,0 +1,9 @@
import LoggingView from "../views/loggingView";
export default function LoggingPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<LoggingView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import MCPGatewayPage from "./page";
export const Route = createFileRoute("/workspace/config/mcp-gateway")({
component: MCPGatewayPage,
});

View File

@@ -0,0 +1,9 @@
import MCPGatewayView from "../views/mcpView";
export default function MCPGatewayPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<MCPGatewayView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ObservabilityPage from "./page";
export const Route = createFileRoute("/workspace/config/observability")({
component: ObservabilityPage,
});

View File

@@ -0,0 +1,9 @@
import ObservabilityView from "../views/observabilityView";
export default function ObservabilityPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<ObservabilityView />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
export default function ConfigPage() {
const navigate = useNavigate();
// Check permission
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
useEffect(() => {
if (hasConfigAccess) {
navigate({ to: "/workspace/config/client-settings", replace: true });
}
}, [hasConfigAccess, navigate]);
if (!hasConfigAccess) {
return <NoPermissionView entity="configuration" />;
}
return null;
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import PerformanceTuningPage from "./page";
export const Route = createFileRoute("/workspace/config/performance-tuning")({
component: PerformanceTuningPage,
});

View File

@@ -0,0 +1,9 @@
import PerformanceTuningView from "../views/performanceTuningView";
export default function PerformanceTuningPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<PerformanceTuningView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import PricingConfigPage from "./page";
export const Route = createFileRoute("/workspace/config/pricing-config")({
component: PricingConfigPage,
});

View File

@@ -0,0 +1,9 @@
import ModelSettingsView from "../views/modelSettingsView";
export default function PricingConfigPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<ModelSettingsView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ProxyPage from "./page";
export const Route = createFileRoute("/workspace/config/proxy")({
component: ProxyPage,
});

View File

@@ -0,0 +1,24 @@
import { IS_ENTERPRISE } from "@/lib/constants/config";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import ProxyView from "../views/proxyView";
export default function ProxyPage() {
const navigate = useNavigate();
useEffect(() => {
if (!IS_ENTERPRISE) {
navigate({ to: "/workspace/config/client-settings", replace: true });
}
}, [navigate]);
if (!IS_ENTERPRISE) {
return null;
}
return (
<div className="mx-auto flex w-full max-w-7xl">
<ProxyView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import SecurityPage from "./page";
export const Route = createFileRoute("/workspace/config/security")({
component: SecurityPage,
});

View File

@@ -0,0 +1,9 @@
import SecurityView from "../views/securityView";
export default function SecurityPage() {
return (
<div className="mx-auto flex w-full max-w-7xl">
<SecurityView />
</div>
);
}

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

View File

@@ -0,0 +1,21 @@
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import CustomPricingPage from "./page";
function CustomPricingLayout({ children }: { children: React.ReactNode }) {
const hasSettingsAccess = useRbac(RbacResource.Settings, RbacOperation.View);
if (!hasSettingsAccess) {
return <NoPermissionView entity="custom pricing" />;
}
return <>{children}</>;
}
function RouteComponent() {
const childMatches = useChildMatches();
return <CustomPricingLayout>{childMatches.length === 0 ? <CustomPricingPage /> : <Outlet />}</CustomPricingLayout>;
}
export const Route = createFileRoute("/workspace/custom-pricing")({
component: RouteComponent,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ScopedPricingOverridesPage from "./page";
export const Route = createFileRoute("/workspace/custom-pricing/overrides")({
component: ScopedPricingOverridesPage,
});

Some files were not shown because too many files have changed in this diff Show More