From 49c65cead8b1112a9eb3422a7864757b6b7f90eb Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 28 Jul 2026 00:08:30 -0700 Subject: [PATCH] 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. --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../_components/CostOptimizationView.tsx | 2 +- .../_components/PromptCompressionTab.test.tsx | 185 +++++++++ .../_components/PromptCompressionTab.tsx | 363 ++++++++++++------ .../cost-optimization/_components/helpers.ts | 7 + .../_components/GuardrailsPanel.test.tsx | 33 +- .../_components/GuardrailsPanel.tsx | 17 +- .../_components/guardrail_info.test.tsx | 68 ++++ .../guardrails/_components/guardrail_info.tsx | 12 +- .../guardrails/detailNavigation.test.ts | 74 ++++ .../guardrails/detailNavigation.ts | 52 +++ 11 files changed, 696 insertions(+), 122 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 1c07f2d6247..b9a10530110 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 3bab6afee57..1c0562ed58a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -28,7 +28,7 @@ const CostOptimizationView: React.FC = ({ accessToken { key: "compression", label: "Prompt Compression", - children: , + children: , }, { key: "autorouter", 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 new file mode 100644 index 00000000000..88f166ba8a6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.test.tsx @@ -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 = {}) => ({ + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const list = await screen.findByRole("list"); + expect(within(list).getAllByRole("listitem")).toHaveLength(1); + expect(screen.queryByText("presidio-pii")).not.toBeInTheDocument(); + }); +}); 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 071ad1d6bd5..8fe773bf2e0 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 @@ -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> = { + 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 = ({ accessToken }) => { - const [form] = Form.useForm(); +const CompressionEndpointRow: React.FC = ({ + 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 ( +
  • +
    +

    {name}

    +

    {guardrail.litellm_params?.api_base ?? ""}

    + {isFromConfig && ( +

    + Defined in the proxy config file, so it is read-only here +

    + )} +
    + +
    + {isEditable ? ( + + ) : ( + {MODE_LABELS[mode]} + )} + + {canEdit && ( + + )} +
    + + {mode === "opt_in" && ( +

    + Compression only runs when a request asks for it:{" "} + "guardrails": ["{name}"] in the request + body. Turning it on for a specific key or team instead is a LiteLLM Enterprise feature +

    + )} +
  • + ); +}; + +const PromptCompressionTab: React.FC = ({ accessToken, userRole }) => { + const router = useRouter(); + const isAdmin = userRole ? isAdminRole(userRole) : false; + const [guardrails, setGuardrails] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [pendingModeId, setPendingModeId] = useState(null); + 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) { - 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 = ({ 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) => { + 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 = ({ accessToken } }; - return ( -
    - - - Headroom prompt compression - - -

    - 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.{" "} - - Headroom setup docs - -

    - {isLoading &&

    Loading...

    } - {!isLoading && guardrails.length === 0 && ( -

    - No prompt compression guardrails configured yet. Add one below to start saving on input tokens -

    - )} - {!isLoading && guardrails.length > 0 && ( -
      - {guardrails.map((guardrail) => ( -
    • -
      -

      {guardrail.guardrail_name}

      -

      {guardrail.litellm_params?.api_base ?? ""}

      -
      - - {guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"} - -
    • - ))} -
    - )} -
    -
    + const hasGuardrails = guardrails.length > 0; + const isFormVisible = isAdmin && !isLoading && (!hasGuardrails || isAddFormOpen); - - - Add Headroom compression guardrail - - -
    + + Headroom prompt compression + + 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.{" "} + - - - - - - - - - -
    -

    - 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 + + + + + + {isLoading && } + + {!isLoading && hasGuardrails && ( +

      + {guardrails.map((guardrail) => ( + + ))} +
    + )} + + {!isLoading && !hasGuardrails && !isFormVisible && ( +

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

    + )} + + {!isLoading && hasGuardrails && isAdmin && !isAddFormOpen && ( + + )} + + {isFormVisible && ( + +
    + + setName(event.target.value)} + placeholder="headroom-compression" + aria-invalid={showFieldErrors && !name.trim()} + /> + {showFieldErrors && !name.trim() &&

    Name is required

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

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

    + {showFieldErrors && !apiBase.trim() &&

    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.{" "} - here + Get a trial key

    -
    - + )} +
    - - - -
    + + )} +
    +
    ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/helpers.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/helpers.ts index 7c8c92f8890..5d9796a468c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/helpers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/helpers.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 29655e5910a..2526f393b7e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -30,9 +30,18 @@ vi.mock("./guardrail_table", () => ({ ), })); +const mockGuardrailInfoProps = vi.fn(); + vi.mock("./guardrail_info", () => ({ __esModule: true, - default: () =>
    Mock Guardrail Info View
    , + default: (props: any) => { + mockGuardrailInfoProps(props); + return
    Mock Guardrail Info View
    ; + }, +})); + +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(); + 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(); + 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(); fireEvent.click(screen.getByText("Guardrails")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx index 33c6933634c..b78d747ccd9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx @@ -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 = ({ accessToken, userRole const [isDeleting, setIsDeleting] = useState(false); const [guardrailToDelete, setGuardrailToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedGuardrailId, setSelectedGuardrailId] = useState(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 = ({ 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 = ({ accessToken, userRole {selectedGuardrailId ? ( setSelectedGuardrailId(null)} + onClose={close} accessToken={accessToken} isAdmin={isAdmin} + initialTab={guardrailDetailTab} /> ) : ( setSelectedGuardrailId(id)} + onGuardrailClick={openGuardrail} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx index 7bb7737e152..29a6d33f735 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx @@ -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( + {}} 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( + {}} + 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({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index 07df6ff15d9..332d7bdb6ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -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 = ({ guardrailId, onClose, accessToken, isAdmin }) => { +const GuardrailInfoView: React.FC = ({ + guardrailId, + onClose, + accessToken, + isAdmin, + initialTab = "overview", +}) => { const [guardrailData, setGuardrailData] = useState(null); const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState(null); const [loading, setLoading] = useState(true); @@ -512,7 +520,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose,
    - + Overview {isAdmin ? Settings : <>} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.test.ts new file mode 100644 index 00000000000..0029fcfb17d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.ts new file mode 100644 index 00000000000..3c54bbf3a8d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/detailNavigation.ts @@ -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, + }; +}