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, + }; +}