diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f52288ea72a..cbd64a178b8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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, 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..e45fb318e42 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -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", }; }; 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 + + ), + + + + + + + ) : ( + + )} + + ); +}; + +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 ( + + + + ); +}; + +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 ( + <> + 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..c3cde0be47c --- /dev/null +++ b/ui/litellm-dashboard/src/contexts/ProxyConnectionContext.tsx @@ -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) => 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); + 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 => { + 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} + + ); +};