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
This commit is contained in:
Krish Dholakia
2025-07-19 16:32:02 -07:00
committed by GitHub
parent 6239ff05bb
commit ec01ec923b
7 changed files with 542 additions and 18 deletions
+1
View File
@@ -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(
@@ -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.
@@ -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]
@@ -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<AdminPanelProps> = ({
showSSOBanner,
premiumUser,
proxySettings,
userRole,
}) => {
const [form] = Form.useForm();
const [memberForm] = Form.useForm();
@@ -551,6 +554,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
<TabList>
<Tab>Security Settings</Tab>
<Tab>SCIM</Tab>
<Tab>Useful Links</Tab>
</TabList>
<TabPanels>
<TabPanel>
@@ -705,6 +709,12 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel>
<UsefulLinksManagement
accessToken={accessToken}
userRole={userRole || null}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
@@ -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<ModelHubTableProps> = ({
</div>
</div>
{/* Filters */}
<ModelFilters
modelHubData={modelHubData || []}
onFilteredDataChange={handleFilteredDataChange}
/>
{/* Model Table */}
<ModelDataTable
columns={modelHubColumns(
showModal,
copyToClipboard,
publicPage,
)}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
{/* Useful Links Management Section for Admins */}
{isAdminRole(userRole || "") && (
<div className="mt-8 mb-2">
<UsefulLinksManagement
accessToken={accessToken}
userRole={userRole}
/>
</div>
)}
{/* Model Filters and Table */}
<Card>
{/* Filters */}
<ModelFilters
modelHubData={modelHubData || []}
onFilteredDataChange={handleFilteredDataChange}
/>
{/* Model Table */}
<ModelDataTable
columns={modelHubColumns(
showModal,
copyToClipboard,
publicPage,
)}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
</Card>
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</Text>
</div>
</div>
) : (
<Card className="mx-auto max-w-xl mt-10">
@@ -2104,6 +2104,29 @@ export const modelExceptionsCall = async (
}
};
export const updateUsefulLinksCall = async (accessToken: String, useful_links: Record<string, string>) => {
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;
}
};
@@ -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<UsefulLinksManagementProps> = ({
accessToken,
userRole,
}) => {
const [links, setLinks] = useState<Link[]>([]);
const [newLink, setNewLink] = useState({ url: "", displayName: "" });
const [editingLink, setEditingLink] = useState<Link | null>(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<string, string> = {};
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: (
<div className="py-4">
<p className="text-gray-600 mb-4">
Your useful links have been saved and are now visible on the public model hub.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800 mb-2 font-medium">
View your updated model hub:
</p>
<a
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 hover:text-blue-800 underline text-sm font-medium"
>
Open Public Model Hub
</a>
</div>
</div>
),
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 (
<Card className="mb-6">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex flex-col">
<Title className="mb-0">Link Management</Title>
<p className="text-sm text-gray-500">Manage the links that are displayed under &apos;Useful Links&apos; on the public model hub.</p>
</div>
<div className="flex items-center">
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Link</Text>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">URL</label>
<input
type="text"
value={newLink.url}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Display Name
</label>
<input
type="text"
value={newLink.displayName}
onChange={(e) =>
setNewLink({
...newLink,
displayName: e.target.value,
})
}
placeholder="Friendly name"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
<div className="flex items-end">
<button
onClick={handleAddLink}
disabled={!newLink.url || !newLink.displayName}
className={`flex items-center px-4 py-2 rounded-md text-sm ${!newLink.url || !newLink.displayName ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-green-600 text-white hover:bg-green-700'}`}
>
<PlusCircleIcon className="w-4 h-4 mr-1" />
Add Link
</button>
</div>
</div>
</div>
<Text className="text-sm font-medium text-gray-700 mb-2">
Manage Existing Links
</Text>
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableRow>
<TableHeaderCell className="py-1 h-8">
Display Name
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
URL
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
Actions
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{links.map((link) => (
<TableRow key={link.id} className="h-8">
{editingLink && editingLink.id === link.id ? (
<>
<TableCell className="py-0.5">
<input
type="text"
value={editingLink.displayName}
onChange={(e) =>
setEditingLink({
...editingLink,
displayName: e.target.value,
})
}
className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm"
/>
</TableCell>
<TableCell className="py-0.5">
<input
type="text"
value={editingLink.url}
onChange={(e) =>
setEditingLink({
...editingLink,
url: e.target.value,
})
}
className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm"
/>
</TableCell>
<TableCell className="py-0.5 whitespace-nowrap">
<div className="flex space-x-2">
<button
onClick={handleUpdateLink}
className="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="text-xs bg-gray-50 text-gray-600 px-2 py-1 rounded hover:bg-gray-100"
>
Cancel
</button>
</div>
</TableCell>
</>
) : (
<>
<TableCell className="py-0.5 text-sm text-gray-900">
{link.displayName}
</TableCell>
<TableCell className="py-0.5 text-sm text-gray-500">
{link.url}
</TableCell>
<TableCell className="py-0.5 whitespace-nowrap">
<div className="flex space-x-2">
<button
onClick={() => setCurrentLink(link.url)}
className="text-xs bg-green-50 text-green-600 px-2 py-1 rounded hover:bg-green-100"
>
Use
</button>
<button
onClick={() => handleEditLink(link)}
className="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100"
>
<PencilIcon className="w-3 h-3" />
</button>
<button
onClick={() => deleteLink(link.id)}
className="text-xs bg-red-50 text-red-600 px-2 py-1 rounded hover:bg-red-100"
>
<TrashIcon className="w-3 h-3" />
</button>
</div>
</TableCell>
</>
)}
</TableRow>
))}
{links.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="py-0.5 text-sm text-gray-500 text-center"
>
No links added yet. Add a new link above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</Card>
);
};
export default UsefulLinksManagement;