mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): add pause/resume Switch to the models table (#28151)
Squash-merged by litellm-agent from Cyberfilo's PR.
This commit is contained in:
parent
62f6c06042
commit
56f430ebcc
4 changed files with 164 additions and 4 deletions
|
|
@ -7,7 +7,7 @@ import { columns } from "@/components/molecules/models/columns";
|
|||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelDeleteCall } from "@/components/networking";
|
||||
import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
|
||||
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -220,6 +220,25 @@ const AllModelsTab = ({
|
|||
}
|
||||
};
|
||||
|
||||
const [pausingModelId, setPausingModelId] = useState<string | null>(null);
|
||||
|
||||
const handleTogglePause = async (modelId: string, blocked: boolean) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setPausingModelId(modelId);
|
||||
await modelPatchUpdateCall(accessToken, { blocked }, modelId);
|
||||
NotificationsManager.success(blocked ? "Model paused" : "Model resumed");
|
||||
// invalidateQueries already schedules a refetch for active observers
|
||||
// on this key — no need to also call refetchModels() (would double-fetch).
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
} catch (error) {
|
||||
console.error("Error toggling model pause state:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
} finally {
|
||||
setPausingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<Grid>
|
||||
|
|
@ -536,6 +555,8 @@ const AllModelsTab = ({
|
|||
expandedRows,
|
||||
setExpandedRows,
|
||||
setDeleteModalModelId,
|
||||
handleTogglePause,
|
||||
pausingModelId,
|
||||
)}
|
||||
data={filteredData}
|
||||
isLoading={isLoadingModelsInfo}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface ModelInfo {
|
|||
team_id: string;
|
||||
db_model: boolean;
|
||||
access_groups: string[] | null;
|
||||
blocked?: boolean;
|
||||
}
|
||||
|
||||
export interface LiteLLMParams {
|
||||
|
|
|
|||
|
|
@ -944,4 +944,108 @@ describe("columns", () => {
|
|||
expect(screen.getByText("Out: $0.03")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/In:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("pause/resume toggle", () => {
|
||||
const renderWithToggle = (
|
||||
overrides: Partial<ReturnType<typeof createMockModel>["model_info"]> = {},
|
||||
togglePauseHandler?: ReturnType<typeof vi.fn>,
|
||||
userRole: string = "Admin",
|
||||
) => {
|
||||
const handler = togglePauseHandler ?? vi.fn();
|
||||
const cols = columns(
|
||||
userRole,
|
||||
defaultProps.userID,
|
||||
defaultProps.premiumUser,
|
||||
defaultProps.setSelectedModelId,
|
||||
defaultProps.setSelectedTeamId,
|
||||
defaultProps.getDisplayModelName,
|
||||
defaultProps.handleEditClick,
|
||||
defaultProps.handleRefreshClick,
|
||||
defaultProps.expandedRows,
|
||||
defaultProps.setExpandedRows,
|
||||
vi.fn(),
|
||||
handler,
|
||||
);
|
||||
const model = createMockModel({
|
||||
model_info: { ...createMockModel().model_info, ...overrides },
|
||||
});
|
||||
render(<TestTable data={[model]} columns={cols} />);
|
||||
return { handler };
|
||||
};
|
||||
|
||||
it("renders the toggle ON for a db_model that is not blocked", () => {
|
||||
renderWithToggle({ db_model: true, blocked: false });
|
||||
const toggle = screen.getByRole("switch", { name: /pause model/i });
|
||||
expect(toggle).toBeEnabled();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("renders the toggle OFF for a db_model that is blocked", () => {
|
||||
renderWithToggle({ db_model: true, blocked: true });
|
||||
const toggle = screen.getByRole("switch", { name: /resume model/i });
|
||||
expect(toggle).toBeEnabled();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("calls the handler with blocked=true when an admin flips an active toggle off", async () => {
|
||||
const handler = vi.fn();
|
||||
renderWithToggle({ db_model: true, blocked: false }, handler);
|
||||
await userEvent.click(screen.getByRole("switch", { name: /pause model/i }));
|
||||
expect(handler).toHaveBeenCalledWith("test-model-id", true);
|
||||
});
|
||||
|
||||
it("calls the handler with blocked=false when an admin flips a paused toggle on", async () => {
|
||||
const handler = vi.fn();
|
||||
renderWithToggle({ db_model: true, blocked: true }, handler);
|
||||
await userEvent.click(screen.getByRole("switch", { name: /resume model/i }));
|
||||
expect(handler).toHaveBeenCalledWith("test-model-id", false);
|
||||
});
|
||||
|
||||
it("disables the toggle for non-admin users", () => {
|
||||
const handler = vi.fn();
|
||||
renderWithToggle({ db_model: true, blocked: false }, handler, "User");
|
||||
const toggle = screen.getByRole("switch", { name: /pause model/i });
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables the toggle for config models", () => {
|
||||
const handler = vi.fn();
|
||||
renderWithToggle({ db_model: false, blocked: false }, handler, "Admin");
|
||||
const toggle = screen.getByRole("switch", { name: /pause model/i });
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables the toggle while a PATCH for the same row is in-flight", () => {
|
||||
// Regression for Greptile P1 on PR #28151 — antd's `loading` prop is
|
||||
// visual only and does not prevent click events, so the row needs to
|
||||
// be explicitly disabled while its PATCH is pending to avoid
|
||||
// racing/conflicting PATCH calls on double-click.
|
||||
const handler = vi.fn();
|
||||
const model = createMockModel({
|
||||
model_info: {
|
||||
...createMockModel().model_info,
|
||||
db_model: true,
|
||||
blocked: false,
|
||||
},
|
||||
});
|
||||
const cols = columns(
|
||||
"Admin",
|
||||
defaultProps.userID,
|
||||
defaultProps.premiumUser,
|
||||
defaultProps.setSelectedModelId,
|
||||
defaultProps.setSelectedTeamId,
|
||||
defaultProps.getDisplayModelName,
|
||||
defaultProps.handleEditClick,
|
||||
defaultProps.handleRefreshClick,
|
||||
defaultProps.expandedRows,
|
||||
defaultProps.setExpandedRows,
|
||||
vi.fn(),
|
||||
handler,
|
||||
model.model_info.id, // pausingModelId matches this row
|
||||
);
|
||||
render(<TestTable data={[model]} columns={cols} />);
|
||||
const toggle = screen.getByRole("switch", { name: /pause model/i });
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icon
|
|||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Button, Icon } from "@tremor/react";
|
||||
import { Divider, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
|
||||
import { ModelData } from "../../model_dashboard/types";
|
||||
import { ProviderLogo } from "./ProviderLogo";
|
||||
|
||||
|
|
@ -53,6 +53,8 @@ export const columns = (
|
|||
expandedRows: Set<string>,
|
||||
setExpandedRows: (expandedRows: Set<string>) => void,
|
||||
onDeleteClick?: (modelId: string) => void,
|
||||
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>,
|
||||
pausingModelId?: string | null,
|
||||
): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model ID</span>,
|
||||
|
|
@ -398,15 +400,47 @@ export const columns = (
|
|||
{
|
||||
id: "actions",
|
||||
header: () => <span className="text-sm font-semibold">Actions</span>,
|
||||
size: 60,
|
||||
minSize: 40,
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const isAdmin = userRole === "Admin";
|
||||
const isBlocked = model.model_info?.blocked === true;
|
||||
const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
|
||||
const pauseTooltip = isConfigModel
|
||||
? "Config models cannot be paused from the dashboard. Pause is DB-backed."
|
||||
: !isAdmin
|
||||
? "Only proxy admins can pause or resume a model."
|
||||
: isBlocked
|
||||
? "Resume model — restore normal routing."
|
||||
: "Pause model — stop routing requests until resumed.";
|
||||
// antd's `loading` prop on Switch is purely cosmetic — it does not block
|
||||
// clicks. Pair `loading` with `disabled` derived from the same condition
|
||||
// so a double-click during a pending PATCH cannot send a second,
|
||||
// conflicting `blocked` value.
|
||||
const isPausing = pausingModelId === model.model_info.id;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pr-4">
|
||||
<Tooltip title={pauseTooltip}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={!isBlocked}
|
||||
disabled={!isPauseToggleable || isPausing}
|
||||
loading={isPausing}
|
||||
aria-label={isBlocked ? "Resume model" : "Pause model"}
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onChange={(nextChecked) => {
|
||||
if (isPauseToggleable && onTogglePauseClick) {
|
||||
void onTogglePauseClick(model.model_info.id, !nextChecked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isConfigModel ? (
|
||||
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
|
||||
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue