feat(ui): make the Headroom compression card editable

The Prompt Compression tab could only create a Headroom guardrail and then
show it as a static "Always on" badge; switching that endpoint to opt-in, or
changing anything else about it, meant hunting for the guardrail by name on
the Guardrails page. The tab now owns the one knob that belongs to a cost
page, an Always on / Opt-in select that PATCHes only default_on so the rest of
litellm_params survives the merge, and hands off everything else (api_key,
model, unreachable_fallback, delete) to the guardrail detail view it now links
to. Picking Opt-in explains what a caller has to send for compression to run,
since the mode is otherwise invisible from the request side.

Deep linking needed the guardrail detail view to be addressable, so its
selection moved out of GuardrailsPanel state into ?guardrail= and
?guardrail_tab= query params, following the useKeyDetailRouting pattern the
api-keys page already uses. That also gives the Guardrails page working back
navigation and shareable links to a single guardrail.

Create, edit, and the mode select are gated on admin, matching the backend:
internal users may call /guardrails/list but not POST or PATCH /guardrails, so
the old always-visible form handed non-admins a guaranteed 403. Guardrails
defined in the proxy config file stay read-only here for the same reason the
detail page refuses to edit them.

The card was rebuilt on shadcn/ui primitives (Card, Select, Badge, Button,
Input, Label, Switch, Skeleton) and no longer imports antd, which is
lint-banned for new dashboard UI; its eslint suppression is pruned.
This commit is contained in:
Tin Chi Lo 2026-07-28 00:08:30 -07:00
parent daf22ec871
commit 49c65cead8
11 changed files with 696 additions and 122 deletions

View file

@ -220,11 +220,6 @@
"count": 1
}
},
"src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
"local/filename-pascal-case": {
"count": 1

View file

@ -28,7 +28,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
{
key: "compression",
label: "Prompt Compression",
children: <PromptCompressionTab accessToken={accessToken} />,
children: <PromptCompressionTab accessToken={accessToken} userRole={userRole} />,
},
{
key: "autorouter",

View file

@ -0,0 +1,185 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetGuardrailsList = vi.fn();
const mockCreateGuardrailCall = vi.fn();
const mockUpdateGuardrailCall = vi.fn();
const mockPush = vi.fn();
vi.mock("@/components/networking", () => ({
serverRootPath: "/",
getGuardrailsList: (...args: unknown[]) => mockGetGuardrailsList(...args),
createGuardrailCall: (...args: unknown[]) => mockCreateGuardrailCall(...args),
updateGuardrailCall: (...args: unknown[]) => mockUpdateGuardrailCall(...args),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
import PromptCompressionTab from "./PromptCompressionTab";
const compressionGuardrail = (overrides: Record<string, unknown> = {}) => ({
guardrail_id: "fee65a60",
guardrail_name: "headroom-compression",
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: true },
guardrail_definition_location: "db",
...overrides,
});
describe("PromptCompressionTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetGuardrailsList.mockResolvedValue({ guardrails: [compressionGuardrail()] });
});
it("switches an always-on endpoint to opt-in without clobbering its other params", async () => {
mockUpdateGuardrailCall.mockResolvedValue({});
mockGetGuardrailsList.mockResolvedValueOnce({ guardrails: [compressionGuardrail()] }).mockResolvedValueOnce({
guardrails: [
compressionGuardrail({
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: false },
}),
],
});
const user = userEvent.setup();
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByLabelText("Compression mode for headroom-compression"));
await user.click(await screen.findByRole("option", { name: "Opt-in" }));
await waitFor(() =>
expect(mockUpdateGuardrailCall).toHaveBeenCalledWith("sk-test", "fee65a60", {
litellm_params: { default_on: false },
}),
);
expect(await screen.findByText(/only runs when a request asks for it/)).toBeInTheDocument();
});
it("sends default_on true when switching back to always on", async () => {
mockUpdateGuardrailCall.mockResolvedValue({});
mockGetGuardrailsList.mockResolvedValue({
guardrails: [
compressionGuardrail({
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: false },
}),
],
});
const user = userEvent.setup();
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByLabelText("Compression mode for headroom-compression"));
await user.click(await screen.findByRole("option", { name: "Always on" }));
await waitFor(() =>
expect(mockUpdateGuardrailCall).toHaveBeenCalledWith("sk-test", "fee65a60", {
litellm_params: { default_on: true },
}),
);
});
it("deep-links Edit settings to the guardrail's settings tab", async () => {
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
fireEvent.click(await screen.findByRole("button", { name: /Edit settings/ }));
expect(mockPush).toHaveBeenCalledWith("/ui/guardrails?guardrail=fee65a60&guardrail_tab=settings");
});
it("shows a read-only badge instead of edit controls for non-admins", async () => {
render(<PromptCompressionTab accessToken="sk-test" userRole="Internal User" />);
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();
});
it("keeps a config-file guardrail read-only even for an admin", async () => {
mockGetGuardrailsList.mockResolvedValue({
guardrails: [compressionGuardrail({ guardrail_definition_location: "config" })],
});
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
expect(await screen.findByText(/Defined in the proxy config file/)).toBeInTheDocument();
expect(screen.queryByLabelText("Compression mode for headroom-compression")).not.toBeInTheDocument();
});
it("creates a guardrail from the empty state and refreshes the list", async () => {
mockGetGuardrailsList.mockResolvedValueOnce({ guardrails: [] }).mockResolvedValueOnce({
guardrails: [compressionGuardrail()],
});
mockCreateGuardrailCall.mockResolvedValue({});
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
fireEvent.change(await screen.findByLabelText("Name"), { target: { value: "headroom-compression" } });
fireEvent.change(screen.getByLabelText("Headroom API base"), {
target: { value: "https://headroom.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Add guardrail" }));
await waitFor(() =>
expect(mockCreateGuardrailCall).toHaveBeenCalledWith("sk-test", {
guardrail_name: "headroom-compression",
litellm_params: {
guardrail: "headroom",
mode: "pre_call",
api_base: "https://headroom.example.com",
default_on: true,
},
}),
);
expect(await screen.findByText("headroom-compression")).toBeInTheDocument();
});
it("blocks submission and flags the empty fields instead of creating a blank guardrail", async () => {
mockGetGuardrailsList.mockResolvedValue({ guardrails: [] });
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
fireEvent.click(await screen.findByRole("button", { name: "Add guardrail" }));
expect(await screen.findByText("Name is required")).toBeInTheDocument();
expect(screen.getByText("API base is required")).toBeInTheDocument();
expect(mockCreateGuardrailCall).not.toHaveBeenCalled();
});
it("hides the add form behind an explicit action once an endpoint exists", async () => {
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
expect(await screen.findByText("headroom-compression")).toBeInTheDocument();
expect(screen.queryByLabelText("Headroom API base")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Add another endpoint/ }));
expect(await screen.findByLabelText("Headroom API base")).toBeInTheDocument();
});
it("ignores guardrails from other providers", async () => {
mockGetGuardrailsList.mockResolvedValue({
guardrails: [
compressionGuardrail(),
{
guardrail_id: "other",
guardrail_name: "presidio-pii",
litellm_params: { guardrail: "presidio", default_on: true },
guardrail_definition_location: "db",
},
],
});
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
const list = await screen.findByRole("list");
expect(within(list).getAllByRole("listitem")).toHaveLength(1);
expect(screen.queryByText("presidio-pii")).not.toBeInTheDocument();
});
});

View file

@ -1,39 +1,139 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Button, Form, Input, Switch } from "antd";
import { useRouter } from "next/navigation";
import { Plus, Settings2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
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 NotificationsManager from "@/components/molecules/notifications_manager";
import { guardrailDetailHref } from "@/app/(dashboard)/guardrails/detailNavigation";
import { isAdminRole } from "@/utils/roles";
import {
buildCompressionGuardrailPayload,
compressionGuardrailsOf,
GuardrailListItem,
GuardrailListResponse,
isConfigDefinedGuardrail,
} from "./helpers";
interface PromptCompressionTabProps {
accessToken: string | null;
userRole?: string;
}
interface CompressionFormValues {
name: string;
apiBase: string;
defaultOn: boolean;
type CompressionMode = "always" | "opt_in";
const MODE_LABELS: Readonly<Record<CompressionMode, string>> = {
always: "Always on",
opt_in: "Opt-in",
};
const modeOf = (guardrail: GuardrailListItem): CompressionMode =>
guardrail.litellm_params?.default_on ? "always" : "opt_in";
interface CompressionEndpointRowProps {
guardrail: GuardrailListItem;
canEdit: boolean;
isPending: boolean;
onModeChange: (guardrail: GuardrailListItem, mode: CompressionMode) => void;
onEditSettings: (guardrail: GuardrailListItem) => void;
}
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
const [form] = Form.useForm<CompressionFormValues>();
const CompressionEndpointRow: React.FC<CompressionEndpointRowProps> = ({
guardrail,
canEdit,
isPending,
onModeChange,
onEditSettings,
}) => {
const mode = modeOf(guardrail);
const name = guardrail.guardrail_name ?? guardrail.guardrail_id;
const isFromConfig = isConfigDefinedGuardrail(guardrail);
const isEditable = canEdit && !isFromConfig;
const handleSelect = (value: unknown) => {
if (value !== "always" && value !== "opt_in") {
return;
}
onModeChange(guardrail, value);
};
return (
<li className="flex flex-wrap items-center justify-between gap-4 p-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{name}</p>
<p className="truncate text-xs text-muted-foreground">{guardrail.litellm_params?.api_base ?? ""}</p>
{isFromConfig && (
<p className="mt-1 text-xs text-muted-foreground">
Defined in the proxy config file, so it is read-only here
</p>
)}
</div>
<div className="flex items-center gap-2">
{isEditable ? (
<Select value={mode} onValueChange={handleSelect} disabled={isPending}>
<SelectTrigger size="sm" className="w-[124px]" aria-label={`Compression mode for ${name}`}>
<SelectValue>{MODE_LABELS[mode]}</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(MODE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Badge variant={mode === "always" ? "secondary" : "outline"}>{MODE_LABELS[mode]}</Badge>
)}
{canEdit && (
<Button variant="outline" size="sm" onClick={() => onEditSettings(guardrail)}>
<Settings2 />
Edit settings
</Button>
)}
</div>
{mode === "opt_in" && (
<p className="w-full rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
Compression only runs when a request asks for it:{" "}
<code className="font-mono text-foreground">&quot;guardrails&quot;: [&quot;{name}&quot;]</code> in the request
body. Turning it on for a specific key or team instead is a LiteLLM Enterprise feature
</p>
)}
</li>
);
};
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken, userRole }) => {
const router = useRouter();
const isAdmin = userRole ? isAdminRole(userRole) : false;
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [pendingModeId, setPendingModeId] = useState<string | null>(null);
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) {
return;
return Promise.resolve();
}
getGuardrailsList(accessToken)
return getGuardrailsList(accessToken)
.then((response) => setGuardrails(compressionGuardrailsOf(response as GuardrailListResponse)))
.catch((error) => {
console.error("Failed to load compression guardrails:", error);
@ -46,22 +146,50 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
loadGuardrails();
}, [loadGuardrails]);
const handleAdd = async (values: CompressionFormValues) => {
const handleModeChange = async (guardrail: GuardrailListItem, mode: CompressionMode) => {
if (!accessToken) {
return;
}
const nextDefaultOn = mode === "always";
setPendingModeId(guardrail.guardrail_id);
try {
await updateGuardrailCall(accessToken, guardrail.guardrail_id, {
litellm_params: { default_on: nextDefaultOn },
});
NotificationsManager.success(
nextDefaultOn ? "Compression now runs on every request" : "Compression is now opt-in per request",
);
await loadGuardrails();
} catch (error) {
console.error("Failed to update compression guardrail:", error);
NotificationsManager.fromBackend("Failed to update compression guardrail");
} finally {
setPendingModeId(null);
}
};
const handleEditSettings = (guardrail: GuardrailListItem) => {
router.push(guardrailDetailHref(guardrail.guardrail_id, "settings"));
};
const handleAdd = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!accessToken) {
return;
}
if (!name.trim() || !apiBase.trim()) {
setShowFieldErrors(true);
return;
}
setIsSaving(true);
try {
await createGuardrailCall(
accessToken,
buildCompressionGuardrailPayload({
name: values.name,
apiBase: values.apiBase,
defaultOn: values.defaultOn ?? true,
}),
);
await createGuardrailCall(accessToken, buildCompressionGuardrailPayload({ name, apiBase, defaultOn }));
NotificationsManager.success("Compression guardrail created");
form.resetFields();
setName("");
setApiBase("");
setDefaultOn(true);
setShowFieldErrors(false);
setIsAddFormOpen(false);
await loadGuardrails();
} catch (error) {
console.error("Failed to create compression guardrail:", error);
@ -71,105 +199,124 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
}
};
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>Headroom prompt compression</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
Headroom is a native LiteLLM guardrail that compresses your prompts before they reach the model, so you pay
for fewer input tokens. The tokens it removes are priced and shown on the Usage tab as compression savings.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/headroom"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
Headroom setup docs
</a>
</p>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{!isLoading && guardrails.length === 0 && (
<p className="text-sm text-muted-foreground">
No prompt compression guardrails configured yet. Add one below to start saving on input tokens
</p>
)}
{!isLoading && guardrails.length > 0 && (
<ul className="divide-y divide-gray-200">
{guardrails.map((guardrail) => (
<li key={guardrail.guardrail_id} className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium text-foreground">{guardrail.guardrail_name}</p>
<p className="text-xs text-muted-foreground">{guardrail.litellm_params?.api_base ?? ""}</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
guardrail.litellm_params?.default_on
? "bg-emerald-100 text-emerald-800"
: "bg-gray-100 text-gray-600"
}`}
>
{guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
const hasGuardrails = guardrails.length > 0;
const isFormVisible = isAdmin && !isLoading && (!hasGuardrails || isAddFormOpen);
<Card>
<CardHeader>
<CardTitle>Add Headroom compression guardrail</CardTitle>
</CardHeader>
<CardContent>
<Form
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleAdd}
initialValues={{ defaultOn: true }}
return (
<Card>
<CardHeader>
<CardTitle>Headroom prompt compression</CardTitle>
<CardDescription>
Headroom is a native LiteLLM guardrail that compresses your prompts before they reach the model, so you pay
for fewer input tokens. The tokens it removes are priced and shown on the Usage tab as compression savings.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/headroom"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline underline-offset-4"
>
<Form.Item name="name" label="Name" rules={[{ required: true, message: "Name is required" }]}>
<Input placeholder="headroom-compression" />
</Form.Item>
<Form.Item
name="apiBase"
label="Headroom API base"
tooltip="Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)"
extra="The URL where your Headroom compression service is hosted"
rules={[{ required: true, message: "API base is required" }]}
>
<Input placeholder="https://your-headroom-endpoint" />
</Form.Item>
<Form.Item name="defaultOn" label="Apply to all requests" valuePropName="checked">
<Switch />
</Form.Item>
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3">
<p className="text-sm text-yellow-800">
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{" "}
Headroom setup docs
</a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && <Skeleton className="h-14 w-full" />}
{!isLoading && hasGuardrails && (
<ul className="divide-y divide-border rounded-lg border border-border">
{guardrails.map((guardrail) => (
<CompressionEndpointRow
key={guardrail.guardrail_id}
guardrail={guardrail}
canEdit={isAdmin}
isPending={pendingModeId === guardrail.guardrail_id}
onModeChange={handleModeChange}
onEditSettings={handleEditSettings}
/>
))}
</ul>
)}
{!isLoading && !hasGuardrails && !isFormVisible && (
<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 && (
<Button variant="ghost" size="sm" onClick={() => setIsAddFormOpen(true)}>
<Plus />
Add another endpoint
</Button>
)}
{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="underline"
className="font-medium text-primary underline underline-offset-4"
>
here
Get a trial key
</a>
</p>
</div>
<div className="flex justify-end">
<Button type="primary" htmlType="submit" loading={isSaving}>
Add guardrail
<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>
</CardContent>
</Card>
</div>
</form>
)}
</CardContent>
</Card>
);
};

View file

@ -1,3 +1,5 @@
import { GuardrailDefinitionLocation } from "@/components/guardrails/types";
export interface GuardrailLitellmParams {
guardrail?: string | null;
api_base?: string | null;
@ -8,6 +10,7 @@ export interface GuardrailListItem {
guardrail_id: string;
guardrail_name: string | null;
litellm_params?: GuardrailLitellmParams | null;
guardrail_definition_location?: GuardrailDefinitionLocation | null;
}
export interface GuardrailListResponse {
@ -22,6 +25,10 @@ export const isCompressionGuardrail = (guardrail: GuardrailListItem): boolean =>
export const compressionGuardrailsOf = (response: GuardrailListResponse): GuardrailListItem[] =>
(response.guardrails ?? []).filter(isCompressionGuardrail);
/** Guardrails declared in the proxy config file are owned by that file; the API cannot rewrite them. */
export const isConfigDefinedGuardrail = (guardrail: GuardrailListItem): boolean =>
guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG;
export interface CompressionGuardrailInput {
name: string;
apiBase: string;

View file

@ -30,9 +30,18 @@ vi.mock("./guardrail_table", () => ({
),
}));
const mockGuardrailInfoProps = vi.fn();
vi.mock("./guardrail_info", () => ({
__esModule: true,
default: () => <div>Mock Guardrail Info View</div>,
default: (props: any) => {
mockGuardrailInfoProps(props);
return <div>Mock Guardrail Info View</div>;
},
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(window.location.search),
}));
vi.mock("./GuardrailTestPlayground", () => ({
@ -83,6 +92,7 @@ describe("GuardrailsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
window.history.pushState(null, "", "/guardrails");
mockGetGuardrailsList.mockResolvedValue({
guardrails: [
{
@ -129,6 +139,27 @@ describe("GuardrailsPanel", () => {
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2);
});
it("should open the detail view on the settings tab from a deep link", async () => {
window.history.pushState(null, "", "/guardrails?guardrail=test-guardrail-1&guardrail_tab=settings");
render(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
expect(await screen.findByText("Mock Guardrail Info View")).toBeInTheDocument();
expect(screen.queryByText("Mock Guardrail Table")).not.toBeInTheDocument();
expect(mockGuardrailInfoProps).toHaveBeenCalledWith(
expect.objectContaining({ guardrailId: "test-guardrail-1", initialTab: "settings" }),
);
});
it("should show the table when no guardrail is deep-linked", async () => {
render(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
expect(await screen.findByText("Mock Guardrail Table")).toBeInTheDocument();
expect(screen.queryByText("Mock Guardrail Info View")).not.toBeInTheDocument();
});
it("should not delete anything when the modal is cancelled", async () => {
render(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));

View file

@ -22,6 +22,7 @@ import { getGuardrailLogoAndName } from "./guardrail_info_helpers";
import { CustomCodeModal } from "./custom_code";
import GuardrailGarden from "./guardrail_garden";
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
import { useGuardrailDetailRouting } from "../detailNavigation";
interface GuardrailsPanelProps {
accessToken: string | null;
@ -40,7 +41,12 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
const [isDeleting, setIsDeleting] = useState(false);
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
const {
guardrailId: selectedGuardrailId,
tab: guardrailDetailTab,
openGuardrail,
close,
} = useGuardrailDetailRouting();
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchGuardrails = async () => {
@ -65,14 +71,14 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
const handleAddGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
close();
}
setIsAddModalVisible(true);
};
const handleAddCustomCodeGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
close();
}
setIsCustomCodeModalVisible(true);
};
@ -166,16 +172,17 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
onClose={close}
accessToken={accessToken}
isAdmin={isAdmin}
initialTab={guardrailDetailTab}
/>
) : (
<GuardrailTable
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
onGuardrailClick={openGuardrail}
/>
)}

View file

@ -166,6 +166,74 @@ describe("Guardrail Info", () => {
}
});
it("should open on the settings tab when linked to it, without a second click", async () => {
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
guardrail_id: "123",
guardrail_name: "Test Guardrail",
litellm_params: {
guardrail: "headroom",
mode: "pre_call",
default_on: true,
},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
guardrail_definition_location: "database",
});
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
supported_entities: [],
supported_actions: [],
pii_entity_categories: [],
supported_modes: ["pre_call"],
});
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const { findByText, getByRole } = render(
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} initialTab="settings" />,
);
expect(await findByText("Guardrail Settings")).toBeInTheDocument();
expect(getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
it("should stay on the overview tab for a non-admin linked to the settings tab", async () => {
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
guardrail_id: "123",
guardrail_name: "Test Guardrail",
litellm_params: {
guardrail: "headroom",
mode: "pre_call",
default_on: true,
},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
guardrail_definition_location: "database",
});
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
supported_entities: [],
supported_actions: [],
pii_entity_categories: [],
supported_modes: ["pre_call"],
});
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const { findAllByText, queryByText } = render(
<GuardrailInfoView
guardrailId="123"
onClose={() => {}}
accessToken="123"
isAdmin={false}
initialTab="settings"
/>,
);
await findAllByText("Test Guardrail");
expect(queryByText("Guardrail Settings")).not.toBeInTheDocument();
});
it("should render the guardrail info", async () => {
// Mock the network responses
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({

View file

@ -27,12 +27,14 @@ import GuardrailOptionalParams from "./guardrail_optional_params";
import GuardrailProviderFields from "./guardrail_provider_fields";
import PiiConfiguration from "./pii_configuration";
import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permission/ToolPermissionRulesEditor";
import type { GuardrailDetailTab } from "../detailNavigation";
export interface GuardrailInfoProps {
guardrailId: string;
onClose: () => void;
accessToken: string | null;
isAdmin: boolean;
initialTab?: GuardrailDetailTab;
}
interface ProviderParam {
@ -51,7 +53,13 @@ interface ProviderParamsResponse {
[provider: string]: { [key: string]: ProviderParam };
}
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, accessToken, isAdmin }) => {
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({
guardrailId,
onClose,
accessToken,
isAdmin,
initialTab = "overview",
}) => {
const [guardrailData, setGuardrailData] = useState<any>(null);
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
const [loading, setLoading] = useState(true);
@ -512,7 +520,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</div>
</div>
<TabGroup>
<TabGroup defaultIndex={isAdmin && initialTab === "settings" ? 1 : 0}>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}

View file

@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { guardrailDetailHref, useGuardrailDetailRouting } from "./detailNavigation";
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
vi.mock("@/components/networking", () => ({ serverRootPath: "/" }));
describe("guardrailDetailHref", () => {
it("links straight to the settings tab when asked for it", () => {
expect(guardrailDetailHref("fee65a60", "settings")).toBe(
"/ui/guardrails?guardrail=fee65a60&guardrail_tab=settings",
);
});
it("omits the tab param for the default overview tab", () => {
expect(guardrailDetailHref("fee65a60")).toBe("/ui/guardrails?guardrail=fee65a60");
});
});
describe("useGuardrailDetailRouting", () => {
beforeEach(() => {
window.history.pushState(null, "", "/guardrails");
});
it("openGuardrail sets ?guardrail= via history.pushState (no full navigation)", () => {
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useGuardrailDetailRouting());
act(() => result.current.openGuardrail("fee65a60"));
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("guardrail=fee65a60"));
spy.mockRestore();
});
it("openGuardrail drops a stale tab param so the detail view opens on overview", () => {
window.history.pushState(null, "", "/guardrails?guardrail=old&guardrail_tab=settings");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useGuardrailDetailRouting());
act(() => result.current.openGuardrail("fee65a60"));
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("guardrail=fee65a60");
expect(url).not.toContain("guardrail_tab");
spy.mockRestore();
});
it("close removes both guardrail params and keeps unrelated ones", () => {
window.history.pushState(null, "", "/guardrails?tab=garden&guardrail=fee65a60&guardrail_tab=settings");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useGuardrailDetailRouting());
act(() => result.current.close());
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("tab=garden");
expect(url).not.toContain("guardrail=");
expect(url).not.toContain("guardrail_tab");
spy.mockRestore();
});
it("reads the guardrail id and settings tab from the query string", () => {
window.history.pushState(null, "", "/guardrails?guardrail=fee65a60&guardrail_tab=settings");
const { result } = renderHook(() => useGuardrailDetailRouting());
expect(result.current.guardrailId).toBe("fee65a60");
expect(result.current.tab).toBe("settings");
});
it("falls back to the overview tab for an unknown tab value", () => {
window.history.pushState(null, "", "/guardrails?guardrail=fee65a60&guardrail_tab=bogus");
const { result } = renderHook(() => useGuardrailDetailRouting());
expect(result.current.tab).toBe("overview");
});
it("guardrailId is null when no guardrail param is present", () => {
const { result } = renderHook(() => useGuardrailDetailRouting());
expect(result.current.guardrailId).toBeNull();
});
});

View file

@ -0,0 +1,52 @@
import { useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { migratedHref } from "@/utils/migratedPages";
import { navigateWithParams } from "../navigateWithParams";
export const GUARDRAIL_PARAM = "guardrail";
export const GUARDRAIL_TAB_PARAM = "guardrail_tab";
export type GuardrailDetailTab = "overview" | "settings";
export interface GuardrailDetailRouting {
guardrailId: string | null;
tab: GuardrailDetailTab;
openGuardrail: (id: string) => void;
close: () => void;
}
/** Same-origin href that opens a guardrail's detail view, e.g. "/ui/guardrails?guardrail=abc&guardrail_tab=settings". */
export function guardrailDetailHref(guardrailId: string, tab: GuardrailDetailTab = "overview"): string {
const params = new URLSearchParams(
tab === "settings"
? { [GUARDRAIL_PARAM]: guardrailId, [GUARDRAIL_TAB_PARAM]: tab }
: { [GUARDRAIL_PARAM]: guardrailId },
);
return `${migratedHref("guardrails")}?${params.toString()}`;
}
export function useGuardrailDetailRouting(): GuardrailDetailRouting {
const searchParams = useSearchParams();
const openGuardrail = useCallback((id: string) => {
navigateWithParams((params) => {
params.set(GUARDRAIL_PARAM, id);
params.delete(GUARDRAIL_TAB_PARAM);
});
}, []);
const close = useCallback(() => {
navigateWithParams((params) => {
params.delete(GUARDRAIL_PARAM);
params.delete(GUARDRAIL_TAB_PARAM);
});
}, []);
return {
guardrailId: searchParams?.get(GUARDRAIL_PARAM) ?? null,
tab: searchParams?.get(GUARDRAIL_TAB_PARAM) === "settings" ? "settings" : "overview",
openGuardrail,
close,
};
}