From ec01ec923b67baaa6fc60d4f85b60e9a68a85f96 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 19 Jul 2025 16:32:02 -0700 Subject: [PATCH] UI - support adding links to model hub (#12776) * feat(model_hub_table.tsx): add ability for admin to add links to model hub allows admin to add model + key request access forms to model hub makes it easier to request access to specific models * refactor(ui/): cleanup ui - consistent styling * fix(useful_links_management.tsx): make tab collapsible and explain purpose * fix(ui/): fix ui linting errors * fix: fix linting error --- litellm/constants.py | 1 + .../model_management_endpoints.py | 70 ++++ .../model_management_endpoints.py | 8 +- .../src/components/admins.tsx | 10 + .../src/components/model_hub_table.tsx | 51 ++- .../src/components/networking.tsx | 25 ++ .../components/useful_links_management.tsx | 395 ++++++++++++++++++ 7 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/useful_links_management.tsx diff --git a/litellm/constants.py b/litellm/constants.py index de62eafab9..afdd95385c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -835,6 +835,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int( LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "default_internal_user_params", "public_model_groups", + "public_model_groups_links", ] SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int( diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 3d07b86f21..9a55b0455a 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -44,6 +44,9 @@ from litellm.proxy.management_endpoints.team_endpoints import ( ) from litellm.proxy.management_helpers.audit_logs import create_object_audit_log from litellm.proxy.utils import PrismaClient +from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + UpdateUsefulLinksRequest, +) from litellm.types.router import ( Deployment, DeploymentTypedDict, @@ -1035,6 +1038,73 @@ async def update_public_model_groups( ) +@router.post( + "/model_hub/update_useful_links", + description="Update useful links", + tags=["model management"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_useful_links( + request: UpdateUsefulLinksRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update useful links. + """ + try: + # Update the public model groups + import litellm + from litellm.proxy.proxy_server import proxy_config + + # Check if user has admin permissions + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins can update public model groups. Your role={}".format( + user_api_key_dict.user_role + ) + }, + ) + + litellm.public_model_groups_links = request.useful_links + + # Load existing config + config = await proxy_config.get_config() + + # Update config with new settings + if "litellm_settings" not in config: + config["litellm_settings"] = {} + + config["litellm_settings"]["public_model_groups_links"] = request.useful_links + + # Save the updated config + await proxy_config.save_config(new_config=config) + + verbose_proxy_logger.info( + f"Updated useful links to: {request.useful_links} by user: {user_api_key_dict.user_id}" + ) + + return { + "message": "Successfully updated useful links", + "useful_links": request.useful_links, + "updated_by": user_api_key_dict.user_id, + } + + except Exception as e: + verbose_proxy_logger.exception(f"Error updating public model groups: {str(e)}") + + if isinstance(e, HTTPException): + raise e + + raise ProxyException( + message=f"Error updating public model groups: {str(e)}", + type=ProxyErrorTypes.internal_server_error, + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + param=None, + ) + + def _deduplicate_litellm_router_models(models: List[Dict]) -> List[Dict]: """ Deduplicate models based on their model_info.id field. diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index 16403b663b..165562d32f 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -1,7 +1,13 @@ -from pydantic import Field +from typing import Dict + +from pydantic import BaseModel, Field from ...router import ModelGroupInfo class ModelGroupInfoProxy(ModelGroupInfo): is_public_model_group: bool = Field(default=False) + + +class UpdateUsefulLinksRequest(BaseModel): + useful_links: Dict[str, str] diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index a876aa9d51..b743dd6f17 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -45,6 +45,7 @@ import SSOModals from "./SSOModals"; import { ssoProviderConfigs } from './SSOModals'; import SCIMConfig from "./SCIM"; import UIAccessControlForm from "./UIAccessControlForm"; +import UsefulLinksManagement from "./useful_links_management"; interface AdminPanelProps { searchParams: any; @@ -54,6 +55,7 @@ interface AdminPanelProps { showSSOBanner: boolean; premiumUser: boolean; proxySettings?: any; + userRole?: string | null; } import { useBaseUrl } from "./constants"; @@ -78,6 +80,7 @@ const AdminPanel: React.FC = ({ showSSOBanner, premiumUser, proxySettings, + userRole, }) => { const [form] = Form.useForm(); const [memberForm] = Form.useForm(); @@ -551,6 +554,7 @@ const AdminPanel: React.FC = ({ Security Settings SCIM + Useful Links @@ -705,6 +709,12 @@ const AdminPanel: React.FC = ({ proxySettings={proxySettings} /> + + + diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx index ab504304ce..6d640aeed5 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -7,6 +7,7 @@ import { modelHubColumns } from "./model_hub_table_columns"; import PublicModelHub from "./public_model_hub"; import MakeModelPublicForm from "./make_model_public_form"; import ModelFilters from "./model_filters"; +import UsefulLinksManagement from "./useful_links_management"; import { Card, Text, @@ -236,30 +237,46 @@ const ModelHubTable: React.FC = ({ - {/* Filters */} - - {/* Model Table */} - + {/* Useful Links Management Section for Admins */} + {isAdminRole(userRole || "") && ( +
+ +
+ )} + + {/* Model Filters and Table */} + + {/* Filters */} + + + {/* Model Table */} + +
Showing {filteredData.length} of {modelHubData?.length || 0} models
+ + ) : ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index e65909e3d3..de1a509c8f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2104,6 +2104,29 @@ export const modelExceptionsCall = async ( } }; +export const updateUsefulLinksCall = async (accessToken: String, useful_links: Record) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ useful_links: useful_links }), + }); + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + return await response.json(); + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +}; + export const modelAvailableCall = async ( accessToken: String, userID: String, @@ -6367,3 +6390,5 @@ export const vectorStoreSearchCall = async ( throw error; } }; + + diff --git a/ui/litellm-dashboard/src/components/useful_links_management.tsx b/ui/litellm-dashboard/src/components/useful_links_management.tsx new file mode 100644 index 0000000000..ec2fb61665 --- /dev/null +++ b/ui/litellm-dashboard/src/components/useful_links_management.tsx @@ -0,0 +1,395 @@ +import React, { useState, useEffect } from "react"; +import { message, Modal } from "antd"; +import { PlusCircleIcon, PencilIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { isAdminRole } from "../utils/roles"; +import { getPublicModelHubInfo, updateUsefulLinksCall, getProxyBaseUrl } from "./networking"; +import { + Card, + Title, + Text, + Table, + TableHead, + TableHeaderCell, + TableBody, + TableRow, + TableCell +} from "@tremor/react"; + +interface UsefulLinksManagementProps { + accessToken: string | null; + userRole: string | null; +} + +interface Link { + id: string; + displayName: string; + url: string; +} + +const UsefulLinksManagement: React.FC = ({ + accessToken, + userRole, +}) => { + const [links, setLinks] = useState([]); + const [newLink, setNewLink] = useState({ url: "", displayName: "" }); + const [editingLink, setEditingLink] = useState(null); + const [loading, setLoading] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); + + const fetchUsefulLinks = async () => { + if (!accessToken) return; + + try { + setLoading(true); + const response = await getPublicModelHubInfo(); + + if (response && response.useful_links) { + const usefulLinks = response.useful_links || {}; + + // Convert object to array of links with ids + const linksArray = Object.entries(usefulLinks).map(([displayName, url], index) => ({ + id: `${index}-${displayName}`, + displayName, + url: url as string, + })); + + setLinks(linksArray); + } else { + setLinks([]); + } + } catch (error) { + console.error("Error fetching useful links:", error); + setLinks([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsefulLinks(); + }, [accessToken]); + + // Check if user is admin + if (!isAdminRole(userRole || "")) { + return null; + } + + const saveLinksToBackend = async (updatedLinks: Link[]) => { + if (!accessToken) return false; + + try { + // Convert array back to object format + const linksObject: Record = {}; + updatedLinks.forEach(link => { + linksObject[link.displayName] = link.url; + }); + + await updateUsefulLinksCall(accessToken, linksObject); + // show success modal with public model hub link + Modal.success({ + title: "Links Saved Successfully", + content: ( +
+

+ Your useful links have been saved and are now visible on the public model hub. +

+
+

+ View your updated model hub: +

+ + Open Public Model Hub → + +
+
+ ), + width: 500, + okText: "Close", + maskClosable: true, + keyboard: true + }); + + return true; + } catch (error) { + console.error("Error saving links:", error); + message.error(`Failed to save links - ${error}`); + return false; + } + }; + + const handleAddLink = async () => { + if (!newLink.url || !newLink.displayName) return; + + // Validate URL + try { + new URL(newLink.url); + } catch { + message.error("Please enter a valid URL"); + return; + } + + // Check for duplicate display names + if (links.some(link => link.displayName === newLink.displayName)) { + message.error("A link with this display name already exists"); + return; + } + + const newLinkObj: Link = { + id: `${Date.now()}-${newLink.displayName}`, + displayName: newLink.displayName, + url: newLink.url, + }; + + const updatedLinks = [...links, newLinkObj]; + + if (await saveLinksToBackend(updatedLinks)) { + setLinks(updatedLinks); + setNewLink({ url: "", displayName: "" }); + message.success("Link added successfully"); + } + }; + + const handleEditLink = (link: Link) => { + setEditingLink({ ...link }); + }; + + const handleUpdateLink = async () => { + if (!editingLink) return; + + // Validate URL + try { + new URL(editingLink.url); + } catch { + message.error("Please enter a valid URL"); + return; + } + + // Check for duplicate display names (excluding current link) + if (links.some(link => link.id !== editingLink.id && link.displayName === editingLink.displayName)) { + message.error("A link with this display name already exists"); + return; + } + + const updatedLinks = links.map(link => + link.id === editingLink.id ? editingLink : link + ); + + if (await saveLinksToBackend(updatedLinks)) { + setLinks(updatedLinks); + setEditingLink(null); + message.success("Link updated successfully"); + } + }; + + const handleCancelEdit = () => { + setEditingLink(null); + }; + + const deleteLink = async (linkId: string) => { + const updatedLinks = links.filter(link => link.id !== linkId); + + if (await saveLinksToBackend(updatedLinks)) { + setLinks(updatedLinks); + message.success("Link deleted successfully"); + } + }; + + const setCurrentLink = (url: string) => { + window.open(url, '_blank'); + }; + + return ( + +
setIsExpanded(!isExpanded)} + > +
+ Link Management +

Manage the links that are displayed under 'Useful Links' on the public model hub.

+
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+ + {isExpanded && ( +
+
+ Add New Link +
+
+ + + setNewLink({ + ...newLink, + url: e.target.value, + }) + } + placeholder="https://example.com" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+
+ + + setNewLink({ + ...newLink, + displayName: e.target.value, + }) + } + placeholder="Friendly name" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+
+ +
+
+
+ + Manage Existing Links + +
+
+ + + + + Display Name + + + URL + + + Actions + + + + + {links.map((link) => ( + + {editingLink && editingLink.id === link.id ? ( + <> + + + setEditingLink({ + ...editingLink, + displayName: e.target.value, + }) + } + className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm" + /> + + + + setEditingLink({ + ...editingLink, + url: e.target.value, + }) + } + className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm" + /> + + +
+ + +
+
+ + ) : ( + <> + + {link.displayName} + + + {link.url} + + +
+ + + +
+
+ + )} +
+ ))} + {links.length === 0 && ( + + + No links added yet. Add a new link above. + + + )} +
+
+
+
+
+ )} +
+ ); +}; + +export default UsefulLinksManagement; \ No newline at end of file