From c89318737491b379dee1fcae6cf51ab848cbd3a6 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 28 Jul 2026 11:49:30 -0700 Subject: [PATCH] feat(ui): delete a compression endpoint from the cost page Switching an endpoint between always-on and opt-in landed on the card last commit, but removing one still meant leaving for the Guardrails page, which is the trip the card was supposed to save. Each row now carries a delete action that opens the same DeleteResourceModal the Guardrails table uses, so the confirmation looks identical wherever it is triggered from, and it names the endpoint, its id, its api base, and whether it currently applies to every request. The warning above those details depends on the mode, since the two modes fail differently: deleting an always-on endpoint silently stops compressing and input costs go back up, while deleting an opt-in one breaks the callers that name it in their request body. Delete is gated exactly like the mode select, on admin and not config-file-defined, matching DELETE /guardrails/{id} and the disabled Delete item the guardrail table already shows for config guardrails. The card body grew past the point where one component could hold it, so the add form and the delete confirmation moved into their own components. The form owns its own fields now, which drops four pieces of state from the tab and means a failed create keeps what was typed instead of the tab having to remember it. --- .../_components/PromptCompressionTab.test.tsx | 53 ++++ .../_components/PromptCompressionTab.tsx | 272 ++++++++++++------ 2 files changed, 242 insertions(+), 83 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx index 88f166ba8a6..96d052fbce4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockGetGuardrailsList = vi.fn(); const mockCreateGuardrailCall = vi.fn(); const mockUpdateGuardrailCall = vi.fn(); +const mockDeleteGuardrailCall = vi.fn(); const mockPush = vi.fn(); vi.mock("@/components/networking", () => ({ @@ -13,6 +14,7 @@ vi.mock("@/components/networking", () => ({ getGuardrailsList: (...args: unknown[]) => mockGetGuardrailsList(...args), createGuardrailCall: (...args: unknown[]) => mockCreateGuardrailCall(...args), updateGuardrailCall: (...args: unknown[]) => mockUpdateGuardrailCall(...args), + deleteGuardrailCall: (...args: unknown[]) => mockDeleteGuardrailCall(...args), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) })); @@ -99,6 +101,56 @@ describe("PromptCompressionTab", () => { expect(await screen.findByText("Always on")).toBeInTheDocument(); expect(screen.queryByLabelText("Compression mode for headroom-compression")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Edit settings/ })).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Delete headroom-compression")).not.toBeInTheDocument(); + }); + + it("deletes an endpoint after the confirmation modal and drops it from the card", async () => { + mockDeleteGuardrailCall.mockResolvedValue({}); + mockGetGuardrailsList + .mockResolvedValueOnce({ guardrails: [compressionGuardrail()] }) + .mockResolvedValueOnce({ guardrails: [] }); + + render(); + + fireEvent.click(await screen.findByLabelText("Delete headroom-compression")); + + const modal = within(await screen.findByRole("dialog")); + expect(modal.getByText(/Every request is compressed by this guardrail today/)).toBeInTheDocument(); + expect(modal.getByText("https://headroom.example.com")).toBeInTheDocument(); + + fireEvent.click(modal.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(mockDeleteGuardrailCall).toHaveBeenCalledWith("sk-test", "fee65a60")); + expect(await screen.findByLabelText("Headroom API base")).toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("does not delete anything when the confirmation is cancelled", async () => { + render(); + + fireEvent.click(await screen.findByLabelText("Delete headroom-compression")); + const modal = within(await screen.findByRole("dialog")); + fireEvent.click(modal.getByRole("button", { name: "Cancel" })); + + expect(mockDeleteGuardrailCall).not.toHaveBeenCalled(); + expect(within(screen.getByRole("list")).getByText("headroom-compression")).toBeInTheDocument(); + }); + + it("warns about failing opt-in callers instead of lost savings when deleting an opt-in endpoint", async () => { + mockGetGuardrailsList.mockResolvedValue({ + guardrails: [ + compressionGuardrail({ + litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: false }, + }), + ], + }); + + render(); + + fireEvent.click(await screen.findByLabelText("Delete headroom-compression")); + + const modal = within(await screen.findByRole("dialog")); + expect(modal.getByText(/Requests that ask for this guardrail by name will fail/)).toBeInTheDocument(); }); it("keeps a config-file guardrail read-only even for an admin", async () => { @@ -110,6 +162,7 @@ describe("PromptCompressionTab", () => { expect(await screen.findByText(/Defined in the proxy config file/)).toBeInTheDocument(); expect(screen.queryByLabelText("Compression mode for headroom-compression")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Delete headroom-compression")).not.toBeInTheDocument(); }); it("creates a guardrail from the empty state and refreshes the list", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx index 8fe773bf2e0..3aa53d9547f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Settings2 } from "lucide-react"; +import { Plus, Settings2, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -12,12 +12,19 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; -import { createGuardrailCall, getGuardrailsList, updateGuardrailCall } from "@/components/networking"; +import { + createGuardrailCall, + deleteGuardrailCall, + getGuardrailsList, + updateGuardrailCall, +} from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { guardrailDetailHref } from "@/app/(dashboard)/guardrails/detailNavigation"; import { isAdminRole } from "@/utils/roles"; import { buildCompressionGuardrailPayload, + CompressionGuardrailInput, compressionGuardrailsOf, GuardrailListItem, GuardrailListResponse, @@ -45,6 +52,7 @@ interface CompressionEndpointRowProps { isPending: boolean; onModeChange: (guardrail: GuardrailListItem, mode: CompressionMode) => void; onEditSettings: (guardrail: GuardrailListItem) => void; + onDelete: (guardrail: GuardrailListItem) => void; } const CompressionEndpointRow: React.FC = ({ @@ -53,6 +61,7 @@ const CompressionEndpointRow: React.FC = ({ isPending, onModeChange, onEditSettings, + onDelete, }) => { const mode = modeOf(guardrail); const name = guardrail.guardrail_name ?? guardrail.guardrail_id; @@ -102,6 +111,18 @@ const CompressionEndpointRow: React.FC = ({ Edit settings )} + + {isEditable && ( + + )} {mode === "opt_in" && ( @@ -115,6 +136,127 @@ const CompressionEndpointRow: React.FC = ({ ); }; +interface AddEndpointFormProps { + isSaving: boolean; + canCancel: boolean; + onCancel: () => void; + onSubmit: (input: CompressionGuardrailInput) => void; +} + +const AddEndpointForm: React.FC = ({ isSaving, canCancel, onCancel, onSubmit }) => { + const [name, setName] = useState(""); + const [apiBase, setApiBase] = useState(""); + const [defaultOn, setDefaultOn] = useState(true); + const [showFieldErrors, setShowFieldErrors] = useState(false); + + const isNameMissing = !name.trim(); + const isApiBaseMissing = !apiBase.trim(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isNameMissing || isApiBaseMissing) { + setShowFieldErrors(true); + return; + } + onSubmit({ name, apiBase, defaultOn }); + }; + + return ( +
+
+ + setName(event.target.value)} + placeholder="headroom-compression" + aria-invalid={showFieldErrors && isNameMissing} + /> + {showFieldErrors && isNameMissing &&

Name is required

} +
+ +
+ + setApiBase(event.target.value)} + placeholder="https://your-headroom-endpoint" + aria-invalid={showFieldErrors && isApiBaseMissing} + /> +

+ Where your Headroom compression service is hosted; LiteLLM calls its /v1/compress endpoint +

+ {showFieldErrors && isApiBaseMissing &&

API base is required

} +
+ +
+ +

+ Off means callers opt in per request. Applying compression to all requests is available to all users; enabling + it selectively per key or team is a LiteLLM Enterprise feature.{" "} + + Get a trial key + +

+
+ +
+ {canCancel && ( + + )} + +
+
+ ); +}; + +interface DeleteEndpointModalProps { + guardrail: GuardrailListItem | null; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteEndpointModal: React.FC = ({ guardrail, isDeleting, onCancel, onConfirm }) => { + const isAlwaysOn = guardrail !== null && modeOf(guardrail) === "always"; + + return ( + + ); +}; + const PromptCompressionTab: React.FC = ({ accessToken, userRole }) => { const router = useRouter(); const isAdmin = userRole ? isAdminRole(userRole) : false; @@ -123,11 +265,9 @@ const PromptCompressionTab: React.FC = ({ accessToken const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [pendingModeId, setPendingModeId] = useState(null); + const [guardrailToDelete, setGuardrailToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); const [isAddFormOpen, setIsAddFormOpen] = useState(false); - const [name, setName] = useState(""); - const [apiBase, setApiBase] = useState(""); - const [defaultOn, setDefaultOn] = useState(true); - const [showFieldErrors, setShowFieldErrors] = useState(false); const loadGuardrails = useCallback(() => { if (!accessToken) { @@ -172,23 +312,32 @@ const PromptCompressionTab: React.FC = ({ accessToken router.push(guardrailDetailHref(guardrail.guardrail_id, "settings")); }; - const handleAdd = async (event: React.FormEvent) => { - event.preventDefault(); - if (!accessToken) { + const handleDeleteConfirm = async () => { + if (!accessToken || !guardrailToDelete) { return; } - if (!name.trim() || !apiBase.trim()) { - setShowFieldErrors(true); + setIsDeleting(true); + try { + await deleteGuardrailCall(accessToken, guardrailToDelete.guardrail_id); + NotificationsManager.success("Compression guardrail deleted"); + setGuardrailToDelete(null); + await loadGuardrails(); + } catch (error) { + console.error("Failed to delete compression guardrail:", error); + NotificationsManager.fromBackend("Failed to delete compression guardrail"); + } finally { + setIsDeleting(false); + } + }; + + const handleAdd = async (input: CompressionGuardrailInput) => { + if (!accessToken) { return; } setIsSaving(true); try { - await createGuardrailCall(accessToken, buildCompressionGuardrailPayload({ name, apiBase, defaultOn })); + await createGuardrailCall(accessToken, buildCompressionGuardrailPayload(input)); NotificationsManager.success("Compression guardrail created"); - setName(""); - setApiBase(""); - setDefaultOn(true); - setShowFieldErrors(false); setIsAddFormOpen(false); await loadGuardrails(); } catch (error) { @@ -200,7 +349,12 @@ const PromptCompressionTab: React.FC = ({ accessToken }; const hasGuardrails = guardrails.length > 0; - const isFormVisible = isAdmin && !isLoading && (!hasGuardrails || isAddFormOpen); + const isSettled = !isLoading; + const wantsAddForm = !hasGuardrails || isAddFormOpen; + const isFormVisible = isAdmin && isSettled && wantsAddForm; + const isListVisible = isSettled && hasGuardrails; + const isEmptyStateVisible = isSettled && !hasGuardrails && !isFormVisible; + const isAddAnotherVisible = isListVisible && isAdmin && !isAddFormOpen; return ( @@ -221,9 +375,9 @@ const PromptCompressionTab: React.FC = ({ accessToken - {isLoading && } + {!isSettled && } - {!isLoading && hasGuardrails && ( + {isListVisible && (
    {guardrails.map((guardrail) => ( = ({ accessToken isPending={pendingModeId === guardrail.guardrail_id} onModeChange={handleModeChange} onEditSettings={handleEditSettings} + onDelete={setGuardrailToDelete} /> ))}
)} - {!isLoading && !hasGuardrails && !isFormVisible && ( + {isEmptyStateVisible && (

No prompt compression endpoint is configured. An admin can add one to start saving on input tokens

)} - {!isLoading && hasGuardrails && isAdmin && !isAddFormOpen && ( + {isAddAnotherVisible && ( - )} - - - + setIsAddFormOpen(false)} + onSubmit={handleAdd} + /> )} + + setGuardrailToDelete(null)} + onConfirm={handleDeleteConfirm} + />
);