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.
This commit is contained in:
Tin Chi Lo 2026-07-28 11:49:30 -07:00
parent 49c65cead8
commit c893187374
2 changed files with 242 additions and 83 deletions

View file

@ -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(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
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(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
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(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
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 () => {

View file

@ -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<CompressionEndpointRowProps> = ({
@ -53,6 +61,7 @@ const CompressionEndpointRow: React.FC<CompressionEndpointRowProps> = ({
isPending,
onModeChange,
onEditSettings,
onDelete,
}) => {
const mode = modeOf(guardrail);
const name = guardrail.guardrail_name ?? guardrail.guardrail_id;
@ -102,6 +111,18 @@ const CompressionEndpointRow: React.FC<CompressionEndpointRowProps> = ({
Edit settings
</Button>
)}
{isEditable && (
<Button
variant="ghost"
size="sm"
aria-label={`Delete ${name}`}
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(guardrail)}
>
<Trash2 />
</Button>
)}
</div>
{mode === "opt_in" && (
@ -115,6 +136,127 @@ const CompressionEndpointRow: React.FC<CompressionEndpointRowProps> = ({
);
};
interface AddEndpointFormProps {
isSaving: boolean;
canCancel: boolean;
onCancel: () => void;
onSubmit: (input: CompressionGuardrailInput) => void;
}
const AddEndpointForm: React.FC<AddEndpointFormProps> = ({ isSaving, canCancel, onCancel, onSubmit }) => {
const [name, setName] = useState<string>("");
const [apiBase, setApiBase] = useState<string>("");
const [defaultOn, setDefaultOn] = useState<boolean>(true);
const [showFieldErrors, setShowFieldErrors] = useState<boolean>(false);
const isNameMissing = !name.trim();
const isApiBaseMissing = !apiBase.trim();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isNameMissing || isApiBaseMissing) {
setShowFieldErrors(true);
return;
}
onSubmit({ name, apiBase, defaultOn });
};
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-border p-4">
<div className="space-y-2">
<Label htmlFor="compression-name">Name</Label>
<Input
id="compression-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="headroom-compression"
aria-invalid={showFieldErrors && isNameMissing}
/>
{showFieldErrors && isNameMissing && <p className="text-xs text-destructive">Name is required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="compression-api-base">Headroom API base</Label>
<Input
id="compression-api-base"
value={apiBase}
onChange={(event) => setApiBase(event.target.value)}
placeholder="https://your-headroom-endpoint"
aria-invalid={showFieldErrors && isApiBaseMissing}
/>
<p className="text-xs text-muted-foreground">
Where your Headroom compression service is hosted; LiteLLM calls its /v1/compress endpoint
</p>
{showFieldErrors && isApiBaseMissing && <p className="text-xs text-destructive">API base is required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="compression-default-on">
<Switch id="compression-default-on" checked={defaultOn} onCheckedChange={setDefaultOn} />
Apply to all requests
</Label>
<p className="text-xs text-muted-foreground">
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.{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline underline-offset-4"
>
Get a trial key
</a>
</p>
</div>
<div className="flex justify-end gap-2">
{canCancel && (
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isSaving}>
{isSaving ? "Adding..." : "Add guardrail"}
</Button>
</div>
</form>
);
};
interface DeleteEndpointModalProps {
guardrail: GuardrailListItem | null;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
const DeleteEndpointModal: React.FC<DeleteEndpointModalProps> = ({ guardrail, isDeleting, onCancel, onConfirm }) => {
const isAlwaysOn = guardrail !== null && modeOf(guardrail) === "always";
return (
<DeleteResourceModal
isOpen={guardrail !== null}
title="Delete compression guardrail"
alertMessage={
isAlwaysOn
? "Every request is compressed by this guardrail today. Deleting it stops compression immediately and input token costs go back up"
: "Requests that ask for this guardrail by name will fail once it is deleted"
}
message={`Are you sure you want to delete guardrail: ${guardrail?.guardrail_name ?? ""}? This action cannot be undone.`}
resourceInformationTitle="Guardrail Information"
resourceInformation={[
{ label: "Name", value: guardrail?.guardrail_name },
{ label: "ID", value: guardrail?.guardrail_id, code: true },
{ label: "API base", value: guardrail?.litellm_params?.api_base },
{ label: "Applies to", value: isAlwaysOn ? MODE_LABELS.always : MODE_LABELS.opt_in },
]}
onCancel={onCancel}
onOk={onConfirm}
confirmLoading={isDeleting}
/>
);
};
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken, userRole }) => {
const router = useRouter();
const isAdmin = userRole ? isAdminRole(userRole) : false;
@ -123,11 +265,9 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [pendingModeId, setPendingModeId] = useState<string | null>(null);
const [guardrailToDelete, setGuardrailToDelete] = useState<GuardrailListItem | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isAddFormOpen, setIsAddFormOpen] = useState<boolean>(false);
const [name, setName] = useState<string>("");
const [apiBase, setApiBase] = useState<string>("");
const [defaultOn, setDefaultOn] = useState<boolean>(true);
const [showFieldErrors, setShowFieldErrors] = useState<boolean>(false);
const loadGuardrails = useCallback(() => {
if (!accessToken) {
@ -172,23 +312,32 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
router.push(guardrailDetailHref(guardrail.guardrail_id, "settings"));
};
const handleAdd = async (event: React.FormEvent<HTMLFormElement>) => {
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<PromptCompressionTabProps> = ({ 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 (
<Card>
@ -221,9 +375,9 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
</CardHeader>
<CardContent className="space-y-4">
{isLoading && <Skeleton className="h-14 w-full" />}
{!isSettled && <Skeleton className="h-14 w-full" />}
{!isLoading && hasGuardrails && (
{isListVisible && (
<ul className="divide-y divide-border rounded-lg border border-border">
{guardrails.map((guardrail) => (
<CompressionEndpointRow
@ -233,18 +387,19 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
isPending={pendingModeId === guardrail.guardrail_id}
onModeChange={handleModeChange}
onEditSettings={handleEditSettings}
onDelete={setGuardrailToDelete}
/>
))}
</ul>
)}
{!isLoading && !hasGuardrails && !isFormVisible && (
{isEmptyStateVisible && (
<p className="text-sm text-muted-foreground">
No prompt compression endpoint is configured. An admin can add one to start saving on input tokens
</p>
)}
{!isLoading && hasGuardrails && isAdmin && !isAddFormOpen && (
{isAddAnotherVisible && (
<Button variant="ghost" size="sm" onClick={() => setIsAddFormOpen(true)}>
<Plus />
Add another endpoint
@ -252,69 +407,20 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
)}
{isFormVisible && (
<form onSubmit={handleAdd} className="space-y-4 rounded-lg border border-border p-4">
<div className="space-y-2">
<Label htmlFor="compression-name">Name</Label>
<Input
id="compression-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="headroom-compression"
aria-invalid={showFieldErrors && !name.trim()}
/>
{showFieldErrors && !name.trim() && <p className="text-xs text-destructive">Name is required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="compression-api-base">Headroom API base</Label>
<Input
id="compression-api-base"
value={apiBase}
onChange={(event) => setApiBase(event.target.value)}
placeholder="https://your-headroom-endpoint"
aria-invalid={showFieldErrors && !apiBase.trim()}
/>
<p className="text-xs text-muted-foreground">
Where your Headroom compression service is hosted; LiteLLM calls its /v1/compress endpoint
</p>
{showFieldErrors && !apiBase.trim() && <p className="text-xs text-destructive">API base is required</p>}
</div>
<div className="space-y-2">
<Label htmlFor="compression-default-on">
<Switch
id="compression-default-on"
checked={defaultOn}
onCheckedChange={(checked) => setDefaultOn(checked)}
/>
Apply to all requests
</Label>
<p className="text-xs text-muted-foreground">
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.{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline underline-offset-4"
>
Get a trial key
</a>
</p>
</div>
<div className="flex justify-end gap-2">
{hasGuardrails && (
<Button type="button" variant="ghost" onClick={() => setIsAddFormOpen(false)}>
Cancel
</Button>
)}
<Button type="submit" disabled={isSaving}>
{isSaving ? "Adding..." : "Add guardrail"}
</Button>
</div>
</form>
<AddEndpointForm
isSaving={isSaving}
canCancel={hasGuardrails}
onCancel={() => setIsAddFormOpen(false)}
onSubmit={handleAdd}
/>
)}
<DeleteEndpointModal
guardrail={guardrailToDelete}
isDeleting={isDeleting}
onCancel={() => setGuardrailToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</CardContent>
</Card>
);