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} + />
);