Merge pull request #17859 from BerriAI/litellm_ui_links_rearrange

[Feature] Model Hub Useful Links Rearrange
This commit is contained in:
yuneng-jiang 2025-12-11 22:12:55 -08:00 committed by GitHub
commit cf5dab7f52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 238 additions and 73 deletions

View file

@ -399,7 +399,10 @@ disable_copilot_system_to_assistant: bool = (
public_mcp_servers: Optional[List[str]] = None
public_model_groups: Optional[List[str]] = None
public_agent_groups: Optional[List[str]] = None
public_model_groups_links: Dict[str, str] = {}
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
# New format: { "displayName": { "url": "...", "index": 0 } }
# Old format: { "displayName": "url" } (for backward compatibility)
public_model_groups_links: Dict[str, Union[str, Dict[str, Any]]] = {}
#### REQUEST PRIORITIZATION #######
priority_reservation: Optional[Dict[str, Union[float, PriorityReservationDict]]] = None
priority_reservation_settings: "PriorityReservationSettings" = (

View file

@ -1,4 +1,4 @@
from typing import Dict, List
from typing import Dict, List, Union, Any
from pydantic import BaseModel, Field
@ -10,7 +10,10 @@ class ModelGroupInfoProxy(ModelGroupInfo):
class UpdateUsefulLinksRequest(BaseModel):
useful_links: Dict[str, str]
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
# New format: { "displayName": { "url": "...", "index": 0 } }
# Old format: { "displayName": "url" } (for backward compatibility)
useful_links: Dict[str, Union[str, Dict[str, Any]]]
class NewModelGroupRequest(BaseModel):

View file

@ -1,4 +1,4 @@
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal, Optional, Union, Any
from pydantic import BaseModel
@ -7,7 +7,10 @@ class PublicModelHubInfo(BaseModel):
docs_title: str
custom_docs_description: Optional[str]
litellm_version: str
useful_links: Optional[Dict[str, str]]
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
# New format: { "displayName": { "url": "...", "index": 0 } }
# Old format: { "displayName": "url" } (for backward compatibility)
useful_links: Optional[Dict[str, Union[str, Dict[str, Any]]]]
class ProviderCredentialField(BaseModel):

View file

@ -1,4 +1,12 @@
import { PencilAltIcon, PlayIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
import {
PencilAltIcon,
PlayIcon,
RefreshIcon,
TrashIcon,
ChevronUpIcon,
ChevronDownIcon,
ExternalLinkIcon,
} from "@heroicons/react/outline";
import { Tooltip } from "antd";
import BaseActionButton from "../BaseActionButton";
@ -21,6 +29,9 @@ export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseP
Delete: { icon: TrashIcon, className: "hover:text-red-600" },
Test: { icon: PlayIcon, className: "hover:text-blue-600" },
Regenerate: { icon: RefreshIcon, className: "hover:text-green-600" },
Up: { icon: ChevronUpIcon, className: "hover:text-blue-600" },
Down: { icon: ChevronDownIcon, className: "hover:text-blue-600" },
Open: { icon: ExternalLinkIcon, className: "hover:text-green-600" },
};
export default function TableIconActionButton({

View file

@ -221,7 +221,8 @@ export interface PublicModelHubInfo {
docs_title: string;
custom_docs_description: string | null;
litellm_version: string;
useful_links: Record<string, string>;
// Supports both old format (Record<string, string>) and new format (Record<string, {url: string, index: number}>)
useful_links: Record<string, string | { url: string; index: number }>;
}
export interface LiteLLMWellKnownUiConfig {
@ -2362,7 +2363,10 @@ export const modelExceptionsCall = async (
}
};
export const updateUsefulLinksCall = async (accessToken: string, useful_links: Record<string, string>) => {
export const updateUsefulLinksCall = async (
accessToken: string,
useful_links: Record<string, string | { url: string; index: number }>,
) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`;
const response = await fetch(url, {

View file

@ -97,7 +97,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const [pageTitle, setPageTitle] = useState<string>("LiteLLM Gateway");
const [customDocsDescription, setCustomDocsDescription] = useState<string | null>(null);
const [litellmVersion, setLitellmVersion] = useState<string>("");
const [usefulLinks, setUsefulLinks] = useState<Record<string, string>>({});
const [usefulLinks, setUsefulLinks] = useState<Record<string, string | { url: string; index: number }>>({});
const [loading, setLoading] = useState<boolean>(true);
const [agentLoading, setAgentLoading] = useState<boolean>(true);
const [mcpLoading, setMcpLoading] = useState<boolean>(true);
@ -976,16 +976,24 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<Card className="mb-10 p-8 bg-white border border-gray-200 rounded-lg shadow-sm">
<Title className="text-2xl font-semibold mb-6 text-gray-900">Useful Links</Title>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(usefulLinks || {}).map(([title, url]) => (
<button
key={title}
onClick={() => window.open(url, "_blank")}
className="flex items-center space-x-3 text-blue-600 hover:text-blue-800 transition-colors p-3 rounded-lg hover:bg-blue-50 border border-gray-200"
>
<ExternalLinkIcon className="w-4 h-4" />
<Text className="text-sm font-medium">{title}</Text>
</button>
))}
{Object.entries(usefulLinks || {})
.map(([title, value]) => {
// Handle both old format (string) and new format ({url, index})
const url = typeof value === "string" ? value : value.url;
const index = typeof value === "string" ? 0 : value.index ?? 0;
return { title, url, index };
})
.sort((a, b) => a.index - b.index)
.map(({ title, url }) => (
<button
key={title}
onClick={() => window.open(url, "_blank")}
className="flex items-center space-x-3 text-blue-600 hover:text-blue-800 transition-colors p-3 rounded-lg hover:bg-blue-50 border border-gray-200"
>
<ExternalLinkIcon className="w-4 h-4" />
<Text className="text-sm font-medium">{title}</Text>
</button>
))}
</div>
</Card>
)}

View file

@ -24,23 +24,17 @@ export const searchToolColumns = (
{
accessorKey: "search_tool_name",
header: "Name",
cell: ({ getValue }) => (
<span className="font-medium">{getValue() as string}</span>
),
cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span>,
},
{
id: "provider",
header: "Provider",
cell: ({ row }) => {
const provider = row.original.litellm_params.search_provider;
const providerInfo = availableProviders.find(p => p.provider_name === provider);
const providerInfo = availableProviders.find((p) => p.provider_name === provider);
const displayName = providerInfo?.ui_friendly_name || provider;
return (
<span className="text-sm">
{displayName}
</span>
);
return <span className="text-sm">{displayName}</span>;
},
},
{
@ -49,11 +43,7 @@ export const searchToolColumns = (
sortingFn: "datetime",
cell: ({ row }) => {
const tool = row.original;
return (
<span className="text-xs">
{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}
</span>
);
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
},
},
{
@ -62,11 +52,7 @@ export const searchToolColumns = (
sortingFn: "datetime",
cell: ({ row }) => {
const tool = row.original;
return (
<span className="text-xs">
{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}
</span>
);
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
},
},
{
@ -90,4 +76,3 @@ export const searchToolColumns = (
),
},
];

View file

@ -64,11 +64,48 @@ describe("UsefulLinksManagement", () => {
await user.click(screen.getByRole("button", { name: /add link/i }));
await waitFor(() =>
expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", { Docs: "https://docs.example.com" }),
expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", {
Docs: { url: "https://docs.example.com", index: 0 },
}),
);
expect(await screen.findByText("Docs")).toBeInTheDocument();
expect(screen.getByText("https://docs.example.com")).toBeInTheDocument();
expect(mockedNotifications.success).toHaveBeenCalledWith("Link added successfully");
});
it("should rearrange links and save the new order", async () => {
const user = userEvent.setup();
mockedGetPublicModelHubInfo.mockResolvedValue({
docs_title: "Docs",
custom_docs_description: null,
litellm_version: "1.0.0",
useful_links: {
"First Link": "https://first.example.com",
"Second Link": "https://second.example.com",
"Third Link": "https://third.example.com",
},
});
render(<UsefulLinksManagement accessToken="token" userRole="Admin" />);
await waitFor(() => expect(screen.getByText("First Link")).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: /rearrange order/i }));
const secondLinkMoveUpButton = screen.getByTestId("move-up-1-Second Link");
await user.click(secondLinkMoveUpButton);
await user.click(screen.getByRole("button", { name: /save order/i }));
await waitFor(() =>
expect(mockedUpdateUsefulLinksCall).toHaveBeenCalledWith("token", {
"Second Link": { url: "https://second.example.com", index: 0 },
"First Link": { url: "https://first.example.com", index: 1 },
"Third Link": { url: "https://third.example.com", index: 2 },
}),
);
expect(mockedNotifications.success).toHaveBeenCalledWith("Link order saved successfully");
});
});

View file

@ -1,10 +1,11 @@
import React, { useState, useEffect } from "react";
import { Modal } from "antd";
import { PlusCircleIcon, PencilIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { PlusCircleIcon, 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";
import NotificationsManager from "./molecules/notifications_manager";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
interface UsefulLinksManagementProps {
accessToken: string | null;
@ -15,6 +16,7 @@ interface Link {
id: string;
displayName: string;
url: string;
index?: number;
}
const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessToken, userRole }) => {
@ -23,6 +25,8 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
const [editingLink, setEditingLink] = useState<Link | null>(null);
const [loading, setLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const [isRearranging, setIsRearranging] = useState(false);
const [originalLinksOrder, setOriginalLinksOrder] = useState<Link[]>([]);
const fetchUsefulLinks = async () => {
if (!accessToken) return;
@ -35,11 +39,32 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
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,
}));
// Handle both old format (Dict[str, str]) and new format (Dict[str, {url, index}])
const linksArray = Object.entries(usefulLinks)
.map(([displayName, value]) => {
// Check if it's the new format with {url, index}
if (typeof value === "object" && value !== null && "url" in value) {
return {
id: `${(value as any).index ?? 0}-${displayName}`,
displayName,
url: (value as any).url as string,
index: (value as any).index ?? 0,
};
} else {
// Old format: just a string URL
return {
id: `0-${displayName}`,
displayName,
url: value as string,
index: 0,
};
}
})
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
.map((link, index) => ({
...link,
id: `${index}-${link.displayName}`,
}));
setLinks(linksArray);
} else {
@ -66,10 +91,14 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
if (!accessToken) return false;
try {
// Convert array back to object format
const linksObject: Record<string, string> = {};
updatedLinks.forEach((link) => {
linksObject[link.displayName] = link.url;
// Convert array back to object format with index for ordering
// New format: { "displayName": { "url": "...", "index": 0 } }
const linksObject: Record<string, { url: string; index: number }> = {};
updatedLinks.forEach((link, index) => {
linksObject[link.displayName] = {
url: link.url,
index: index,
};
});
await updateUsefulLinksCall(accessToken, linksObject);
@ -187,6 +216,42 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
window.open(url, "_blank");
};
const handleStartRearranging = () => {
if (editingLink) {
setEditingLink(null);
}
setOriginalLinksOrder([...links]);
setIsRearranging(true);
};
const handleCancelRearranging = () => {
setLinks([...originalLinksOrder]);
setIsRearranging(false);
setOriginalLinksOrder([]);
};
const handleSaveRearranging = async () => {
if (await saveLinksToBackend(links)) {
setIsRearranging(false);
setOriginalLinksOrder([]);
NotificationsManager.success("Link order saved successfully");
}
};
const handleMoveUp = (index: number) => {
if (index === 0) return;
const newLinks = [...links];
[newLinks[index - 1], newLinks[index]] = [newLinks[index], newLinks[index - 1]];
setLinks(newLinks);
};
const handleMoveDown = (index: number) => {
if (index === links.length - 1) return;
const newLinks = [...links];
[newLinks[index], newLinks[index + 1]] = [newLinks[index + 1], newLinks[index]];
setLinks(newLinks);
};
return (
<Card className="mb-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
@ -252,7 +317,32 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
</div>
</div>
</div>
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Links</Text>
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-medium text-gray-700">Manage Existing Links</Text>
{!isRearranging ? (
<button
onClick={handleStartRearranging}
className="text-xs bg-purple-50 text-purple-600 px-3 py-1.5 rounded hover:bg-purple-100 flex items-center"
>
Rearrange Order
</button>
) : (
<div className="flex space-x-2">
<button
onClick={handleSaveRearranging}
className="text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700"
>
Save Order
</button>
<button
onClick={handleCancelRearranging}
className="text-xs bg-gray-50 text-gray-600 px-3 py-1.5 rounded hover:bg-gray-100"
>
Cancel
</button>
</div>
)}
</div>
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
@ -264,7 +354,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
</TableRow>
</TableHead>
<TableBody>
{links.map((link) => (
{links.map((link, index) => (
<TableRow key={link.id} className="h-8">
{editingLink && editingLink.id === link.id ? (
<>
@ -316,26 +406,47 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<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>
{isRearranging ? (
<div className="flex space-x-2">
<TableIconActionButton
variant="Up"
onClick={() => handleMoveUp(index)}
tooltipText="Move up"
disabled={index === 0}
disabledTooltipText="Already at the top"
dataTestId={`move-up-${link.id}`}
/>
<TableIconActionButton
variant="Down"
onClick={() => handleMoveDown(index)}
tooltipText="Move down"
disabled={index === links.length - 1}
disabledTooltipText="Already at the bottom"
dataTestId={`move-down-${link.id}`}
/>
</div>
) : (
<div className="flex space-x-2">
<TableIconActionButton
variant="Open"
onClick={() => setCurrentLink(link.url)}
tooltipText="Open link"
dataTestId={`open-link-${link.id}`}
/>
<TableIconActionButton
variant="Edit"
onClick={() => handleEditLink(link)}
tooltipText="Edit link"
dataTestId={`edit-link-${link.id}`}
/>
<TableIconActionButton
variant="Delete"
onClick={() => deleteLink(link.id)}
tooltipText="Delete link"
dataTestId={`delete-link-${link.id}`}
/>
</div>
)}
</TableCell>
</>
)}