diff --git a/docs/my-website/docs/proxy/control_plane_and_data_plane.md b/docs/my-website/docs/proxy/control_plane_and_data_plane.md index b0fe2b71ee2..b92ad34d2fe 100644 --- a/docs/my-website/docs/proxy/control_plane_and_data_plane.md +++ b/docs/my-website/docs/proxy/control_plane_and_data_plane.md @@ -27,7 +27,40 @@ When scaling LiteLLM for production use, you may want to deploy multiple instanc ### Typical Deployment Scenario - + + +```mermaid +flowchart TD + subgraph CP["Control Plane (Admin Instance)"] + UI["Admin UI"] + ADMIN_API["Management APIs/key/*, /user/*, /team/*"] + end + + subgraph DP_US["Data Plane - US Region"] + WORKER_US["Worker Instance/chat/completions, /v1/*"] + end + + subgraph DP_EU["Data Plane - EU Region"] + WORKER_EU["Worker Instance/chat/completions, /v1/*"] + end + + DB[("Shared PostgreSQLDatabase")] + + UI -->|"Manage keys, teams,models, config"| ADMIN_API + UI -->|"Switch proxy URLto view data plane"| WORKER_US + UI -->|"Switch proxy URLto view data plane"| WORKER_EU + ADMIN_API --> DB + WORKER_US --> DB + WORKER_EU --> DB + + USER_US["US Users"] -->|"LLM requests"| WORKER_US + USER_EU["EU Users"] -->|"LLM requests"| WORKER_EU + ADMIN["Admins"] -->|"Administration"| UI + + style CP fill:#e6f3ff,stroke:#4a90d9 + style DP_US fill:#e6ffe6,stroke:#4a9950 + style DP_EU fill:#e6ffe6,stroke:#4a9950 +``` ### Benefits of This Architecture @@ -206,6 +239,92 @@ response = requests.post( ) ``` +## UI Multi-Proxy Switcher + +The LiteLLM Admin UI supports switching between multiple proxy instances directly from the browser. This allows administrators to manage all data planes from a single control plane UI without needing separate browser tabs or logins. + +```mermaid +sequenceDiagram + participant Admin + participant UI as Admin UI + participant CP as Control Plane Proxy + participant DP as Data Plane Proxy + + Admin->>UI: Open Admin UI + UI->>CP: Load dashboard (default proxy) + Admin->>UI: Add data plane connection(URL + API key) + UI->>DP: Test connection (/health/readiness) + DP-->>UI: OK (v1.x.x) + Admin->>UI: Switch to data plane + UI->>UI: Save selection to localStorage + UI->>UI: Reload page + UI->>DP: Load dashboard from data plane + Admin->>UI: Switch back to control plane + UI->>CP: Load dashboard from control plane +``` + +### How It Works + +1. Log into the Admin UI on your control plane instance +2. Click the proxy switcher in the navbar (appears after adding a second connection) +3. Select **Manage Connections** to add a data plane +4. Enter the data plane's URL and an API key (master key or virtual key) +5. Click **Test Connection** to verify connectivity +6. Save and switch between proxies using the dropdown + +### Adding a Data Plane Connection + +From the Admin UI navbar, open **Manage Connections** and provide: + +| Field | Description | Example | +|-------|-------------|---------| +| **Name** | A label for this connection | `Production US-East` | +| **Proxy URL** | Full URL of the data plane | `https://us-east.litellm.company.com` | +| **API Key** | Master key or virtual key for auth | `sk-...` | + +### Requirements + +- **CORS**: Each data plane proxy must have CORS configured to allow requests from the control plane UI's origin. LiteLLM's FastAPI CORS middleware handles this — ensure `allow_origins` includes your control plane domain. +- **API Key**: Since the UI authenticates to remote proxies via API key (not cookie-based SSO), provide a key with sufficient permissions for the operations you need. +- **Network Access**: The admin's browser must be able to reach each data plane URL directly. + +### Architecture with Independent Databases + +For deployments where each data plane has its own database (true multi-tenancy), the UI switcher allows viewing and managing each independently: + +```mermaid +flowchart TD + subgraph CP["Control Plane"] + UI["Admin UI(Proxy Switcher)"] + CP_PROXY["Admin Proxy"] + CP_DB[("Control Plane DB")] + end + + subgraph DP1["Data Plane 1"] + DP1_PROXY["Worker Proxy"] + DP1_DB[("Data Plane 1 DB")] + end + + subgraph DP2["Data Plane 2"] + DP2_PROXY["Worker Proxy"] + DP2_DB[("Data Plane 2 DB")] + end + + UI -->|"Default connection"| CP_PROXY + UI -.->|"Switch to DP1"| DP1_PROXY + UI -.->|"Switch to DP2"| DP2_PROXY + + CP_PROXY --> CP_DB + DP1_PROXY --> DP1_DB + DP2_PROXY --> DP2_DB + + style CP fill:#e6f3ff,stroke:#4a90d9 + style DP1 fill:#fff3e6,stroke:#d9944a + style DP2 fill:#fff3e6,stroke:#d9944a +``` + +Connections are stored in browser `localStorage`, so they persist across sessions and require no backend configuration. + ## Related Documentation - [Virtual Keys](./virtual_keys.md) - Managing API keys and users diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.ts index 147fdc241b0..7467f5b26cd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.ts @@ -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({ 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 }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 0b60971c1eb..ce253143094 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -1,10 +1,11 @@ "use client"; -import { getProxyBaseUrl } from "@/components/networking"; +import { getProxyBaseUrl, getGlobalLitellmHeaderName } 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 { useEffect, useMemo, useState } from "react"; import { useUIConfig } from "./uiConfig/useUIConfig"; function formatUserRole(userRole: string) { @@ -39,37 +40,84 @@ 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); + + // Fetch actual user info from remote proxy to get the real role + const [remoteUserInfo, setRemoteUserInfo] = useState<{ + user_role: string | null; + user_id: string | null; + user_email: string | null; + }>({ user_role: null, user_id: null, user_email: null }); + + useEffect(() => { + if (!isRemoteProxy || !activeConnection?.apiKey) return; + + const fetchRemoteUserInfo = async () => { + try { + const url = `${getProxyBaseUrl()}/user/info`; + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${activeConnection.apiKey}`, + "Content-Type": "application/json", + }, + }); + if (response.ok) { + const data = await response.json(); + setRemoteUserInfo({ + user_role: data?.user_role ?? null, + user_id: data?.user_id ?? null, + user_email: data?.user_email ?? null, + }); + } + } catch { + // Silently fail — role will fall back to "Admin" + } + }; + + fetchRemoteUserInfo(); + }, [isRemoteProxy, activeConnection?.apiKey]); // eslint-disable-line react-hooks/exhaustive-deps // 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 ? (remoteUserInfo.user_id ?? null) : (decoded?.user_id ?? null), + userEmail: isRemoteProxy ? (remoteUserInfo.user_email ?? null) : (decoded?.user_email ?? null), + userRole: isRemoteProxy + ? formatUserRole(remoteUserInfo.user_role ?? "proxy_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", }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 1cf7adf1ea9..303f2d82fc6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -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 ( Loading...}> - {children} + + {children} + ); } diff --git a/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ManageProxiesModal.tsx b/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ManageProxiesModal.tsx new file mode 100644 index 00000000000..41bf16540c8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ManageProxiesModal.tsx @@ -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 = ({ open, onClose }) => { + const { connections, activeConnection, addConnection, updateConnection, removeConnection, testConnection } = + useProxyConnection(); + + const [form] = Form.useForm(); + const [editingId, setEditingId] = useState(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 ( + + + Manage Proxy Connections + + } + open={open} + onCancel={() => { + resetForm(); + onClose(); + }} + footer={null} + width={600} + > + ( + + {conn.id === activeConnection?.id ? "Active" : "Local"} + , + ] + : [ + conn.id === activeConnection?.id && ( + + Active + + ), + } onClick={() => handleEdit(conn)} />, + } + onClick={() => handleDelete(conn)} + />, + ].filter(Boolean) + } + > + + {conn.id === activeConnection?.id && } + {conn.name} + + } + description={{conn.url}} + /> + + )} + /> + + {showForm ? ( + + + {editingId ? "Edit Connection" : "Add Connection"} + + + + + + + + + + + + + {testResult && ( + + {testResult.message} + + )} + + + + Test Connection + + + {editingId ? "Update" : "Add"} + + Cancel + + + + ) : ( + } + onClick={() => { + setShowForm(true); + setEditingId(null); + setTestResult(null); + }} + style={{ marginTop: 16, width: "100%" }} + > + Add Connection + + )} + + ); +}; + +export default ManageProxiesModal; diff --git a/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ProxySwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ProxySwitcher.tsx new file mode 100644 index 00000000000..3c5d994ed1d --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/ProxySwitcher/ProxySwitcher.tsx @@ -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 = ({ 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: ( + + {conn.id === activeConnection?.id && } + {conn.name} + {conn.isDefault && Local} + + ), + onClick: () => { + if (conn.id !== activeConnection?.id) { + switchConnection(conn.id); + } + }, + })), + { type: "divider" as const }, + { + key: "manage", + icon: , + label: "Manage Connections", + onClick: onManageClick, + }, + ]; + + return ( + + + + + + {activeConnection?.name || "Default"} + + {activeConnection && !activeConnection.isDefault && Remote} + + + + + ); +}; + +export default ProxySwitcher; diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 861fe054646..c7e9f78036a 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -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 = ({ }) => { 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 = ({ }; return ( + <> @@ -122,6 +126,9 @@ const Navbar: React.FC = ({ )} + + setShowManageProxies(true)} /> + {/* Right side nav items */} @@ -146,6 +153,8 @@ const Navbar: React.FC = ({ + setShowManageProxies(false)} /> + > ); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..447c382221b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/contexts/ProxyConnectionContext.tsx b/ui/litellm-dashboard/src/contexts/ProxyConnectionContext.tsx new file mode 100644 index 00000000000..80e126f3edf --- /dev/null +++ b/ui/litellm-dashboard/src/contexts/ProxyConnectionContext.tsx @@ -0,0 +1,233 @@ +"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) => void; + updateConnection: (id: string, updates: Partial>) => void; + removeConnection: (id: string) => void; + switchConnection: (id: string) => void; + testConnection: (url: string, apiKey: string) => Promise; + isRemoteProxy: boolean; +} + +const defaultContextValue: ProxyConnectionContextType = { + connections: [], + activeConnection: null, + addConnection: () => {}, + updateConnection: () => {}, + removeConnection: () => {}, + switchConnection: () => {}, + testConnection: async () => ({ ok: false, error: "No provider" }), + isRemoteProxy: false, +}; + +const ProxyConnectionContext = createContext(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 = ({ children }) => { + const [connections, setConnections] = useState(() => { + const loaded = loadConnections(); + return ensureDefaultConnection(loaded); + }); + + const [activeId, setActiveId] = useState(() => { + 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) => { + 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>) => { + 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); + // Clear the override so getProxyBaseUrl() falls back to window.location.origin + setProxyBaseUrl(null); + 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 => { + 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 ( + + {children} + + ); +};