feat(ui): Add multi-proxy URL switcher for control plane/data plane architecture

Allows users to manage and switch between multiple LiteLLM proxy instances from a single UI, enabling a control plane with multiple independent data planes.

Changes:
- Create ProxyConnectionContext to manage proxy connections in localStorage
- Add ProxySwitcher dropdown in navbar to switch between configured proxies
- Add ManageProxiesModal for adding/editing/removing proxy connections
- Update useAuthorized to use API key for remote proxies instead of cookie JWT
- Disable UI config fetch for remote proxies to prevent URL overwriting
- Add setProxyBaseUrl export to allow context to update proxy URL

When users switch proxies, the page reloads with the new proxy URL in the global, ensuring all 272 API functions use the correct endpoint. Remote proxies require CORS configured to allow the UI's origin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-02-28 17:55:10 -08:00
parent 0f4771fe19
commit c446b82cc9
9 changed files with 562 additions and 11 deletions

View file

@ -16289,7 +16289,7 @@
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
"litellm_provider": "vertex_ai-language-models",
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"supports_reasoning": false,

View file

@ -1,14 +1,18 @@
import { getUiConfig, LiteLLMWellKnownUiConfig } from "@/components/networking";
import { useProxyConnection } from "@/contexts/ProxyConnectionContext";
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const uiConfigKeys = createQueryKeys("uiConfig");
export const useUIConfig = () => {
const { isRemoteProxy } = useProxyConnection();
return useQuery<LiteLLMWellKnownUiConfig>({
queryKey: uiConfigKeys.list({}),
queryFn: async () => await getUiConfig(),
staleTime: 24 * 60 * 60 * 1000, // 24 hours - data rarely changes
gcTime: 24 * 60 * 60 * 1000, // 24 hours - keep in cache for 24 hours
enabled: !isRemoteProxy, // Don't fetch UI config from remote proxies — it would overwrite proxyBaseUrl
});
};

View file

@ -3,6 +3,7 @@
import { getProxyBaseUrl } from "@/components/networking";
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils";
import { useProxyConnection } from "@/contexts/ProxyConnectionContext";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useUIConfig } from "./uiConfig/useUIConfig";
@ -39,37 +40,46 @@ function formatUserRole(userRole: string) {
const useAuthorized = () => {
const router = useRouter();
const { data: uiConfig, isLoading: isUIConfigLoading } = useUIConfig();
const { activeConnection, isRemoteProxy } = useProxyConnection();
const token = typeof document !== "undefined" ? getCookie("token") : null;
const decoded = useMemo(() => decodeToken(token), [token]);
const isTokenValid = useMemo(() => checkTokenValidity(token), [token]);
const isLoading = isUIConfigLoading;
const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled;
const isLoading = isUIConfigLoading && !isRemoteProxy;
// For remote proxies, authorized if we have a stored API key
const isRemoteAuthorized = isRemoteProxy && !!activeConnection?.apiKey;
const isAuthorized = isRemoteAuthorized || (isTokenValid && !uiConfig?.admin_ui_disabled);
// Single useEffect for all redirect logic
useEffect(() => {
if (isLoading) return;
if (!isAuthorized) {
if (!isAuthorized && !isRemoteProxy) {
if (token) {
clearTokenCookies();
}
router.replace(`${getProxyBaseUrl()}/ui/login`);
}
}, [isLoading, isAuthorized, token, router]);
}, [isLoading, isAuthorized, isRemoteProxy, token, router]);
// For remote proxies, use the stored API key as the access token
const effectiveAccessToken = isRemoteProxy
? activeConnection?.apiKey ?? null
: decoded?.key ?? null;
return {
isLoading,
isAuthorized,
token: isAuthorized ? token : null,
accessToken: decoded?.key ?? null,
userId: decoded?.user_id ?? null,
userEmail: decoded?.user_email ?? null,
userRole: formatUserRole(decoded?.user_role),
accessToken: effectiveAccessToken,
userId: isRemoteProxy ? null : (decoded?.user_id ?? null),
userEmail: isRemoteProxy ? null : (decoded?.user_email ?? null),
userRole: isRemoteProxy ? "Admin" : formatUserRole(decoded?.user_role),
premiumUser: decoded?.premium_user ?? null,
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
showSSOBanner: decoded?.login_method === "username_password",
showSSOBanner: isRemoteProxy ? false : decoded?.login_method === "username_password",
};
};

View file

@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useState } from "react";
import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { ProxyConnectionProvider } from "@/contexts/ProxyConnectionContext";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useRouter, useSearchParams } from "next/navigation";
@ -77,7 +78,9 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
<LayoutContent>{children}</LayoutContent>
<ProxyConnectionProvider>
<LayoutContent>{children}</LayoutContent>
</ProxyConnectionProvider>
</Suspense>
);
}

View file

@ -0,0 +1,229 @@
import { useProxyConnection, ProxyConnection } from "@/contexts/ProxyConnectionContext";
import {
CheckCircleFilled,
CloudServerOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { Button, Form, Input, List, Modal, Space, Tag, Typography, message } from "antd";
import React, { useState } from "react";
const { Text } = Typography;
interface ManageProxiesModalProps {
open: boolean;
onClose: () => void;
}
interface ConnectionForm {
name: string;
url: string;
apiKey: string;
}
const ManageProxiesModal: React.FC<ManageProxiesModalProps> = ({ open, onClose }) => {
const { connections, activeConnection, addConnection, updateConnection, removeConnection, testConnection } =
useProxyConnection();
const [form] = Form.useForm<ConnectionForm>();
const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const resetForm = () => {
form.resetFields();
setEditingId(null);
setShowForm(false);
setTestResult(null);
};
const handleTest = async () => {
try {
const values = await form.validateFields(["url", "apiKey"]);
setTesting(true);
setTestResult(null);
const result = await testConnection(values.url, values.apiKey);
if (result.ok) {
setTestResult({ ok: true, message: `Connected successfully (v${result.version || "unknown"})` });
} else {
setTestResult({ ok: false, message: result.error || "Connection failed" });
}
} catch {
// form validation error
} finally {
setTesting(false);
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (editingId) {
updateConnection(editingId, values);
message.success("Connection updated");
} else {
addConnection(values);
message.success("Connection added");
}
resetForm();
} catch {
// form validation error
}
};
const handleEdit = (conn: ProxyConnection) => {
setEditingId(conn.id);
setShowForm(true);
setTestResult(null);
form.setFieldsValue({
name: conn.name,
url: conn.url,
apiKey: conn.apiKey,
});
};
const handleDelete = (conn: ProxyConnection) => {
Modal.confirm({
title: "Remove Connection",
content: `Are you sure you want to remove "${conn.name}"?`,
okText: "Remove",
okType: "danger",
onOk: () => {
removeConnection(conn.id);
message.success("Connection removed");
},
});
};
return (
<Modal
title={
<Space>
<CloudServerOutlined />
Manage Proxy Connections
</Space>
}
open={open}
onCancel={() => {
resetForm();
onClose();
}}
footer={null}
width={600}
>
<List
dataSource={connections}
renderItem={(conn) => (
<List.Item
actions={
conn.isDefault
? [
<Tag key="default" color={conn.id === activeConnection?.id ? "green" : "default"}>
{conn.id === activeConnection?.id ? "Active" : "Local"}
</Tag>,
]
: [
conn.id === activeConnection?.id && (
<Tag key="active" color="green">
Active
</Tag>
),
<Button key="edit" type="text" size="small" icon={<EditOutlined />} onClick={() => handleEdit(conn)} />,
<Button
key="delete"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(conn)}
/>,
].filter(Boolean)
}
>
<List.Item.Meta
title={
<Space>
{conn.id === activeConnection?.id && <CheckCircleFilled style={{ color: "#52c41a" }} />}
{conn.name}
</Space>
}
description={<Text type="secondary" ellipsis>{conn.url}</Text>}
/>
</List.Item>
)}
/>
{showForm ? (
<div style={{ marginTop: 16, padding: 16, border: "1px solid #f0f0f0", borderRadius: 8 }}>
<Text strong style={{ marginBottom: 12, display: "block" }}>
{editingId ? "Edit Connection" : "Add Connection"}
</Text>
<Form form={form} layout="vertical" size="small">
<Form.Item name="name" label="Name" rules={[{ required: true, message: "Enter a name for this connection" }]}>
<Input placeholder="e.g. Production US-East" />
</Form.Item>
<Form.Item
name="url"
label="Proxy URL"
rules={[
{ required: true, message: "Enter the proxy URL" },
{ type: "url", message: "Enter a valid URL" },
]}
>
<Input placeholder="e.g. https://litellm-prod.example.com" />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={[{ required: true, message: "Enter an API key for this proxy" }]}
tooltip="A master key or virtual key for authenticating with the remote proxy"
>
<Input.Password placeholder="sk-..." />
</Form.Item>
{testResult && (
<div
style={{
marginBottom: 12,
padding: 8,
borderRadius: 4,
backgroundColor: testResult.ok ? "#f6ffed" : "#fff2f0",
border: `1px solid ${testResult.ok ? "#b7eb8f" : "#ffccc7"}`,
}}
>
<Text type={testResult.ok ? "success" : "danger"}>{testResult.message}</Text>
</div>
)}
<Space>
<Button onClick={handleTest} loading={testing}>
Test Connection
</Button>
<Button type="primary" onClick={handleSave}>
{editingId ? "Update" : "Add"}
</Button>
<Button onClick={resetForm}>Cancel</Button>
</Space>
</Form>
</div>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => {
setShowForm(true);
setEditingId(null);
setTestResult(null);
}}
style={{ marginTop: 16, width: "100%" }}
>
Add Connection
</Button>
)}
</Modal>
);
};
export default ManageProxiesModal;

View file

@ -0,0 +1,60 @@
import { useProxyConnection } from "@/contexts/ProxyConnectionContext";
import { CloudServerOutlined, CheckCircleFilled, DownOutlined, SettingOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Divider, Dropdown, Space, Tag, Typography } from "antd";
import React from "react";
const { Text } = Typography;
interface ProxySwitcherProps {
onManageClick: () => void;
}
const ProxySwitcher: React.FC<ProxySwitcherProps> = ({ onManageClick }) => {
const { connections, activeConnection, switchConnection } = useProxyConnection();
// Only show when there are multiple connections configured
if (connections.length <= 1) return null;
const items: MenuProps["items"] = [
...connections.map((conn) => ({
key: conn.id,
label: (
<Space>
{conn.id === activeConnection?.id && <CheckCircleFilled style={{ color: "#52c41a" }} />}
<span>{conn.name}</span>
{conn.isDefault && <Tag color="blue">Local</Tag>}
</Space>
),
onClick: () => {
if (conn.id !== activeConnection?.id) {
switchConnection(conn.id);
}
},
})),
{ type: "divider" as const },
{
key: "manage",
icon: <SettingOutlined />,
label: "Manage Connections",
onClick: onManageClick,
},
];
return (
<Dropdown menu={{ items }} trigger={["click"]}>
<Button type="text" size="small">
<Space>
<CloudServerOutlined />
<Text ellipsis style={{ maxWidth: 150 }}>
{activeConnection?.name || "Default"}
</Text>
{activeConnection && !activeConnection.isDefault && <Tag color="orange">Remote</Tag>}
<DownOutlined style={{ fontSize: 10 }} />
</Space>
</Button>
</Dropdown>
);
};
export default ProxySwitcher;

View file

@ -9,6 +9,8 @@ import Link from "next/link";
import React, { useEffect, useState } from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import ManageProxiesModal from "./Navbar/ProxySwitcher/ManageProxiesModal";
import ProxySwitcher from "./Navbar/ProxySwitcher/ProxySwitcher";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
interface NavbarProps {
@ -42,6 +44,7 @@ const Navbar: React.FC<NavbarProps> = ({
}) => {
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const [showManageProxies, setShowManageProxies] = useState(false);
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadiness();
const version = healthData?.litellm_version;
@ -73,6 +76,7 @@ const Navbar: React.FC<NavbarProps> = ({
};
return (
<>
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="w-full">
<div className="flex items-center h-14 px-4">
@ -122,6 +126,9 @@ const Navbar: React.FC<NavbarProps> = ({
)}
</div>
</div>
<div className="flex items-center ml-4">
<ProxySwitcher onManageClick={() => setShowManageProxies(true)} />
</div>
{/* Right side nav items */}
<div className="flex items-center space-x-5 ml-auto">
<CommunityEngagementButtons />
@ -146,6 +153,8 @@ const Navbar: React.FC<NavbarProps> = ({
</div>
</div>
</nav>
<ManageProxiesModal open={showManageProxies} onClose={() => setShowManageProxies(false)} />
</>
);
};

View file

@ -121,6 +121,10 @@ const updateServerRootPath = (receivedServerRootPath: string) => {
serverRootPath = receivedServerRootPath;
};
export function setProxyBaseUrl(url: string | null) {
proxyBaseUrl = url;
}
export const getProxyBaseUrl = (): string => {
if (proxyBaseUrl) {
return proxyBaseUrl;

View file

@ -0,0 +1,232 @@
"use client";
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { getProxyBaseUrl, setProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
const CONNECTIONS_KEY = "litellm_proxy_connections";
const ACTIVE_ID_KEY = "litellm_active_connection_id";
export interface ProxyConnection {
id: string;
name: string;
url: string;
apiKey: string;
isDefault: boolean;
}
interface TestConnectionResult {
ok: boolean;
version?: string;
error?: string;
}
interface ProxyConnectionContextType {
connections: ProxyConnection[];
activeConnection: ProxyConnection | null;
addConnection: (conn: Omit<ProxyConnection, "id" | "isDefault">) => void;
updateConnection: (id: string, updates: Partial<Pick<ProxyConnection, "name" | "url" | "apiKey">>) => void;
removeConnection: (id: string) => void;
switchConnection: (id: string) => void;
testConnection: (url: string, apiKey: string) => Promise<TestConnectionResult>;
isRemoteProxy: boolean;
}
const defaultContextValue: ProxyConnectionContextType = {
connections: [],
activeConnection: null,
addConnection: () => {},
updateConnection: () => {},
removeConnection: () => {},
switchConnection: () => {},
testConnection: async () => ({ ok: false, error: "No provider" }),
isRemoteProxy: false,
};
const ProxyConnectionContext = createContext<ProxyConnectionContextType>(defaultContextValue);
export const useProxyConnection = () => {
return useContext(ProxyConnectionContext);
};
function generateId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
function loadConnections(): ProxyConnection[] {
const raw = getLocalStorageItem(CONNECTIONS_KEY);
if (!raw) return [];
try {
return JSON.parse(raw);
} catch {
return [];
}
}
function saveConnections(connections: ProxyConnection[]) {
setLocalStorageItem(CONNECTIONS_KEY, JSON.stringify(connections));
}
function loadActiveId(): string | null {
return getLocalStorageItem(ACTIVE_ID_KEY);
}
function saveActiveId(id: string) {
setLocalStorageItem(ACTIVE_ID_KEY, id);
}
function ensureDefaultConnection(connections: ProxyConnection[]): ProxyConnection[] {
const hasDefault = connections.some((c) => c.isDefault);
if (hasDefault) return connections;
const defaultUrl = getProxyBaseUrl();
const defaultConn: ProxyConnection = {
id: "default",
name: "Default",
url: defaultUrl,
apiKey: "",
isDefault: true,
};
return [defaultConn, ...connections];
}
interface ProxyConnectionProviderProps {
children: ReactNode;
}
export const ProxyConnectionProvider: React.FC<ProxyConnectionProviderProps> = ({ children }) => {
const [connections, setConnections] = useState<ProxyConnection[]>(() => {
const loaded = loadConnections();
return ensureDefaultConnection(loaded);
});
const [activeId, setActiveId] = useState<string>(() => {
const savedId = loadActiveId();
if (savedId) return savedId;
const defaultConn = connections.find((c) => c.isDefault);
return defaultConn?.id ?? "default";
});
const activeConnection = connections.find((c) => c.id === activeId) ?? connections.find((c) => c.isDefault) ?? null;
const isRemoteProxy = activeConnection !== null && !activeConnection.isDefault;
// On mount, set the proxyBaseUrl for non-default connections
useEffect(() => {
if (activeConnection && !activeConnection.isDefault) {
setProxyBaseUrl(activeConnection.url);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Persist connections to localStorage whenever they change
useEffect(() => {
saveConnections(connections);
}, [connections]);
const addConnection = useCallback((conn: Omit<ProxyConnection, "id" | "isDefault">) => {
const newConn: ProxyConnection = {
...conn,
id: generateId(),
isDefault: false,
};
setConnections((prev) => {
const updated = [...prev, newConn];
saveConnections(updated);
return updated;
});
}, []);
const updateConnection = useCallback((id: string, updates: Partial<Pick<ProxyConnection, "name" | "url" | "apiKey">>) => {
setConnections((prev) => {
const updated = prev.map((c) => (c.id === id ? { ...c, ...updates } : c));
saveConnections(updated);
return updated;
});
}, []);
const removeConnection = useCallback((id: string) => {
setConnections((prev) => {
const conn = prev.find((c) => c.id === id);
if (!conn || conn.isDefault) return prev;
const updated = prev.filter((c) => c.id !== id);
saveConnections(updated);
return updated;
});
// If removing the active connection, switch back to default
if (id === activeId) {
const defaultConn = connections.find((c) => c.isDefault);
if (defaultConn) {
saveActiveId(defaultConn.id);
setProxyBaseUrl(defaultConn.url);
window.location.reload();
}
}
}, [activeId, connections]);
const switchConnection = useCallback((id: string) => {
if (id === activeId) return;
const target = connections.find((c) => c.id === id);
if (!target) return;
saveActiveId(target.id);
if (target.isDefault) {
// Switching back to default — clear the override so getProxyBaseUrl() falls back
setProxyBaseUrl(null);
} else {
setProxyBaseUrl(target.url);
}
window.location.reload();
}, [activeId, connections]);
const testConnection = useCallback(async (url: string, apiKey: string): Promise<TestConnectionResult> => {
try {
const cleanUrl = url.replace(/\/+$/, "");
const response = await fetch(`${cleanUrl}/health/readiness`, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { ok: false, error: errorData?.error || `HTTP ${response.status}` };
}
const data = await response.json();
return { ok: true, version: data?.litellm_version };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Connection failed";
if (message.includes("Failed to fetch") || message.includes("NetworkError")) {
return {
ok: false,
error: `Connection failed. Ensure the proxy is running and CORS is configured to allow requests from ${window.location.origin}`,
};
}
return { ok: false, error: message };
}
}, []);
return (
<ProxyConnectionContext.Provider
value={{
connections,
activeConnection,
addConnection,
updateConnection,
removeConnection,
switchConnection,
testConnection,
isRemoteProxy,
}}
>
{children}
</ProxyConnectionContext.Provider>
);
};