mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
Merge 59c6cbe228 into d7620c77a1
This commit is contained in:
commit
7e3aa5617e
9 changed files with 489 additions and 82 deletions
|
|
@ -0,0 +1,78 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GuardrailDeleteModal } from "./GuardrailDeleteModal";
|
||||
import { deleteGuardrailCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ deleteGuardrailCall: vi.fn() }));
|
||||
vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn() } }));
|
||||
|
||||
const GUARDRAIL = {
|
||||
guardrail_id: "gr-1",
|
||||
guardrail_name: "word-block",
|
||||
litellm_params: { guardrail: "litellm_content_filter", mode: "pre_call", default_on: true },
|
||||
};
|
||||
|
||||
const renderModal = () => {
|
||||
const onClose = vi.fn();
|
||||
const onDeleted = vi.fn();
|
||||
renderWithProviders(
|
||||
<GuardrailDeleteModal guardrail={GUARDRAIL} accessToken="tok" onClose={onClose} onDeleted={onDeleted} />,
|
||||
);
|
||||
return { onClose, onDeleted };
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(GUARDRAIL.guardrail_name), {
|
||||
target: { value: GUARDRAIL.guardrail_name },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
};
|
||||
|
||||
describe("GuardrailDeleteModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("warns about name references and only enables Delete once the exact name is typed", () => {
|
||||
renderModal();
|
||||
|
||||
const warning = screen.getByText(/silently stop running it/);
|
||||
expect(warning).toHaveTextContent("Keys, teams, and policies that list this guardrail");
|
||||
expect(warning).toHaveTextContent("Policy pipeline steps that use it will block every request with a 400 error");
|
||||
expect(warning).toHaveTextContent("unless the step's on_error is next or allow");
|
||||
const confirmButton = screen.getByRole("button", { name: "Delete" });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
fireEvent.change(screen.getByPlaceholderText(GUARDRAIL.guardrail_name), { target: { value: "word" } });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
fireEvent.change(screen.getByPlaceholderText(GUARDRAIL.guardrail_name), {
|
||||
target: { value: GUARDRAIL.guardrail_name },
|
||||
});
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows a success toast naming the guardrail and calls onDeleted after a successful delete", async () => {
|
||||
vi.mocked(deleteGuardrailCall).mockResolvedValue({});
|
||||
const { onClose, onDeleted } = renderModal();
|
||||
|
||||
confirmDelete();
|
||||
|
||||
await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1));
|
||||
expect(deleteGuardrailCall).toHaveBeenCalledWith("tok", GUARDRAIL.guardrail_id);
|
||||
expect(toast.success).toHaveBeenCalledWith('Guardrail "word-block" deleted successfully');
|
||||
expect(toast.fromError).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an error toast and does not call onDeleted when the delete fails", async () => {
|
||||
vi.mocked(deleteGuardrailCall).mockRejectedValue(new Error("boom"));
|
||||
const { onClose, onDeleted } = renderModal();
|
||||
|
||||
confirmDelete();
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(toast.fromError).toHaveBeenCalledWith("Failed to delete guardrail");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(onDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useState } from "react";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { deleteGuardrailCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatGuardrailMode, getGuardrailLogoAndName } from "./guardrail_info_helpers";
|
||||
|
||||
const DANGLING_REFERENCE_WARNING =
|
||||
"Keys, teams, and policies that list this guardrail will silently stop running it. Policy pipeline steps that use it will block every request with a 400 error, unless the step's on_error is next or allow (or its on_fail, when on_error is not set).";
|
||||
|
||||
export interface DeletableGuardrail {
|
||||
guardrail_id: string;
|
||||
guardrail_name?: string | null;
|
||||
litellm_params?: {
|
||||
guardrail?: string;
|
||||
mode?: unknown;
|
||||
default_on?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface GuardrailDeleteModalProps {
|
||||
guardrail: DeletableGuardrail | null;
|
||||
accessToken: string | null;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function GuardrailDeleteModal({ guardrail, accessToken, onClose, onDeleted }: GuardrailDeleteModalProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const guardrailName = guardrail ? guardrail.guardrail_name || guardrail.guardrail_id : "";
|
||||
const providerDisplayName = getGuardrailLogoAndName(guardrail?.litellm_params?.guardrail || "").displayName;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!guardrail || !accessToken) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteGuardrailCall(accessToken, guardrail.guardrail_id);
|
||||
toast.success(`Guardrail "${guardrailName}" deleted successfully`);
|
||||
onClose();
|
||||
onDeleted();
|
||||
} catch {
|
||||
toast.fromError("Failed to delete guardrail");
|
||||
onClose();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteResourceModal
|
||||
isOpen={guardrail !== null}
|
||||
title="Delete Guardrail"
|
||||
alertMessage={DANGLING_REFERENCE_WARNING}
|
||||
message={`Are you sure you want to delete guardrail: ${guardrailName}? This action cannot be undone.`}
|
||||
resourceInformationTitle="Guardrail Information"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: guardrail?.guardrail_name },
|
||||
{ label: "ID", value: guardrail?.guardrail_id, code: true },
|
||||
{ label: "Provider", value: providerDisplayName },
|
||||
{ label: "Mode", value: formatGuardrailMode(guardrail?.litellm_params?.mode) },
|
||||
{ label: "Default On", value: guardrail?.litellm_params?.default_on ? "Yes" : "No" },
|
||||
]}
|
||||
onCancel={onClose}
|
||||
onOk={handleDelete}
|
||||
confirmLoading={isDeleting}
|
||||
requiredConfirmation={guardrailName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Code, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeletableGuardrail, GuardrailDeleteModal } from "./GuardrailDeleteModal";
|
||||
|
||||
interface GuardrailSettingsActionsProps {
|
||||
guardrail: DeletableGuardrail & { guardrail_definition_location?: string };
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onEditCode: () => void;
|
||||
accessToken: string | null;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function GuardrailSettingsActions({
|
||||
guardrail,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onEditCode,
|
||||
accessToken,
|
||||
onDeleted,
|
||||
}: GuardrailSettingsActionsProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (isEditing || guardrail.guardrail_definition_location === "config") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{guardrail.litellm_params?.guardrail === "custom_code" ? (
|
||||
<Button variant="outline" onClick={onEditCode}>
|
||||
<Code />
|
||||
Edit Code
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onEdit}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" onClick={() => setIsModalOpen(true)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
<GuardrailDeleteModal
|
||||
guardrail={isModalOpen ? guardrail : null}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GuardrailsPanel from "./GuardrailsPanel";
|
||||
import {
|
||||
deleteGuardrailCall,
|
||||
getGuardrailInfo,
|
||||
getGuardrailProviderSpecificParams,
|
||||
getGuardrailsList,
|
||||
getGuardrailUISettings,
|
||||
} from "@/components/networking";
|
||||
import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsList: vi.fn(),
|
||||
deleteGuardrailCall: vi.fn(),
|
||||
getGuardrailInfo: vi.fn(),
|
||||
getGuardrailUISettings: vi.fn(),
|
||||
getGuardrailProviderSpecificParams: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./add_guardrail_form", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("./guardrail_garden", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("./GuardrailTestPlayground", () => ({ __esModule: true, default: () => null }));
|
||||
vi.mock("./TeamGuardrailsTab", () => ({ TeamGuardrailsTab: () => null }));
|
||||
|
||||
const DB_GUARDRAIL = {
|
||||
guardrail_id: "db-guardrail-1",
|
||||
guardrail_name: "db-word-block",
|
||||
litellm_params: { guardrail: "litellm_content_filter", mode: "pre_call", default_on: true },
|
||||
guardrail_info: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
guardrail_definition_location: "db",
|
||||
};
|
||||
|
||||
const CONFIG_GUARDRAIL = {
|
||||
...DB_GUARDRAIL,
|
||||
guardrail_id: "config-guardrail-1",
|
||||
guardrail_name: "cfg-word-block",
|
||||
guardrail_definition_location: "config",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const UI_SETTINGS = {
|
||||
supported_entities: [],
|
||||
supported_actions: [],
|
||||
pii_entity_categories: [],
|
||||
supported_modes: ["pre_call", "post_call"],
|
||||
};
|
||||
|
||||
describe("GuardrailsPanel delete from the guardrail detail Settings card", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [DB_GUARDRAIL, CONFIG_GUARDRAIL] });
|
||||
vi.mocked(getGuardrailInfo).mockImplementation(async (_token: string, id: string) =>
|
||||
id === CONFIG_GUARDRAIL.guardrail_id ? CONFIG_GUARDRAIL : DB_GUARDRAIL,
|
||||
);
|
||||
vi.mocked(getGuardrailUISettings).mockResolvedValue(UI_SETTINGS);
|
||||
vi.mocked(getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
});
|
||||
|
||||
const renderDetail = (guardrailId: string) =>
|
||||
renderWithProviders(<GuardrailsPanel accessToken="test-token" userRole="Admin" />, {
|
||||
searchParams: `?guardrail=${guardrailId}`,
|
||||
});
|
||||
|
||||
it("deletes a DB guardrail only after the admin types its name, then returns to the refreshed table", async () => {
|
||||
vi.mocked(deleteGuardrailCall).mockResolvedValue({});
|
||||
renderDetail(DB_GUARDRAIL.guardrail_id);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
expect(
|
||||
modal.getByText(/pipeline steps that use it will block every request with a 400 error/i),
|
||||
).toBeInTheDocument();
|
||||
const confirmButton = modal.getByRole("button", { name: "Delete" });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const confirmationInput = modal.getByPlaceholderText(DB_GUARDRAIL.guardrail_name);
|
||||
fireEvent.change(confirmationInput, { target: { value: "db-word" } });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
fireEvent.change(confirmationInput, { target: { value: DB_GUARDRAIL.guardrail_name } });
|
||||
expect(confirmButton).toBeEnabled();
|
||||
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(deleteGuardrailCall).toHaveBeenCalledWith("test-token", DB_GUARDRAIL.guardrail_id));
|
||||
expect(await screen.findByRole("table")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
expect(getGuardrailsList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the admin on the detail page when the delete request fails", async () => {
|
||||
vi.mocked(deleteGuardrailCall).mockRejectedValue(new Error("boom"));
|
||||
renderDetail(DB_GUARDRAIL.guardrail_id);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
fireEvent.change(modal.getByPlaceholderText(DB_GUARDRAIL.guardrail_name), {
|
||||
target: { value: DB_GUARDRAIL.guardrail_name },
|
||||
});
|
||||
fireEvent.click(modal.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteGuardrailCall).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer Delete on a config guardrail's detail page", async () => {
|
||||
renderDetail(CONFIG_GUARDRAIL.guardrail_id);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
expect(await screen.findByRole("img", { name: "Config guardrail details" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { type UrlUpdateEvent } from "nuqs/adapters/testing";
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GuardrailsPanel from "./GuardrailsPanel";
|
||||
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -9,6 +10,8 @@ vi.mock("@/components/networking", () => ({
|
|||
deleteGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn() } }));
|
||||
|
||||
vi.mock("./add_guardrail_form", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Mock Add Guardrail Form</div>,
|
||||
|
|
@ -140,13 +143,36 @@ describe("GuardrailsPanel", () => {
|
|||
expect(modal.getByText("Delete Guardrail")).toBeInTheDocument();
|
||||
expect(modal.getByText("test-guardrail-1")).toBeInTheDocument();
|
||||
expect(modal.getByText("Test Provider")).toBeInTheDocument();
|
||||
expect(
|
||||
modal.getByText(/pipeline steps that use it will block every request with a 400 error/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(modal.getByRole("button", { name: "Delete" }));
|
||||
const confirmButton = modal.getByRole("button", { name: "Delete" });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
fireEvent.change(modal.getByPlaceholderText("Test Guardrail"), { target: { value: "Test Guardrail" } });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteGuardrailCall).toHaveBeenCalledWith("test-token", "test-guardrail-1");
|
||||
});
|
||||
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2));
|
||||
expect(toast.success).toHaveBeenCalledWith('Guardrail "Test Guardrail" deleted successfully');
|
||||
});
|
||||
|
||||
it("should show an error toast and not refetch when the row delete fails", async () => {
|
||||
mockDeleteGuardrailCall.mockRejectedValue(new Error("boom"));
|
||||
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
||||
fireEvent.click(await screen.findByTestId("delete-button"));
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
fireEvent.change(modal.getByPlaceholderText("Test Guardrail"), { target: { value: "Test Guardrail" } });
|
||||
fireEvent.click(modal.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(toast.fromError).toHaveBeenCalledWith("Failed to delete guardrail");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should mount every tab panel up front so panel state survives tab switches", async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { parseAsString, useQueryState } from "nuqs";
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChevronDown, Code, Plus } from "lucide-react";
|
||||
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
|
||||
import { getGuardrailsList } from "@/components/networking";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,10 +16,8 @@ import GuardrailTable from "./guardrail_table";
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import GuardrailInfoView from "./guardrail_info";
|
||||
import GuardrailTestPlayground from "./GuardrailTestPlayground";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Guardrail } from "@/components/guardrails/types";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { formatGuardrailMode, getGuardrailLogoAndName } from "./guardrail_info_helpers";
|
||||
import { GuardrailDeleteModal } from "./GuardrailDeleteModal";
|
||||
import { CustomCodeModal } from "./custom_code";
|
||||
import GuardrailGarden from "./guardrail_garden";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
|
|
@ -38,9 +36,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [isCustomCodeModalVisible, setIsCustomCodeModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState(
|
||||
"guardrail",
|
||||
parseAsString.withOptions({ history: "push" }),
|
||||
|
|
@ -97,40 +93,10 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
fetchGuardrails();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (guardrailId: string, guardrailName: string) => {
|
||||
const guardrail = guardrailsList.find((g) => g.guardrail_id === guardrailId) || null;
|
||||
setGuardrailToDelete(guardrail);
|
||||
setIsDeleteModalOpen(true);
|
||||
const handleDeleteClick = (guardrailId: string) => {
|
||||
setGuardrailToDelete(guardrailsList.find((g) => g.guardrail_id === guardrailId) || null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!guardrailToDelete || !accessToken) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteGuardrailCall(accessToken, guardrailToDelete.guardrail_id);
|
||||
toast.success(`Guardrail "${guardrailToDelete.guardrail_name}" deleted successfully`);
|
||||
await fetchGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Error deleting guardrail:", error);
|
||||
toast.fromError("Failed to delete guardrail");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
setGuardrailToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setGuardrailToDelete(null);
|
||||
};
|
||||
|
||||
const providerDisplayName =
|
||||
guardrailToDelete && guardrailToDelete.litellm_params
|
||||
? getGuardrailLogoAndName(guardrailToDelete.litellm_params.guardrail).displayName
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<Tabs defaultValue="guardrails">
|
||||
|
|
@ -184,6 +150,10 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
<GuardrailInfoView
|
||||
guardrailId={selectedGuardrailId}
|
||||
onClose={closeGuardrailDetail}
|
||||
onDeleted={() => {
|
||||
closeGuardrailDetail();
|
||||
fetchGuardrails();
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
|
@ -210,24 +180,11 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Guardrail"
|
||||
message={`Are you sure you want to delete guardrail: ${guardrailToDelete?.guardrail_name}? This action cannot be undone.`}
|
||||
resourceInformationTitle="Guardrail Information"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: guardrailToDelete?.guardrail_name },
|
||||
{ label: "ID", value: guardrailToDelete?.guardrail_id, code: true },
|
||||
{ label: "Provider", value: providerDisplayName },
|
||||
{ label: "Mode", value: formatGuardrailMode(guardrailToDelete?.litellm_params.mode) },
|
||||
{
|
||||
label: "Default On",
|
||||
value: guardrailToDelete?.litellm_params.default_on ? "Yes" : "No",
|
||||
},
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={isDeleting}
|
||||
<GuardrailDeleteModal
|
||||
guardrail={guardrailToDelete}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setGuardrailToDelete(null)}
|
||||
onDeleted={fetchGuardrails}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ const saveChanges = async (user: ReturnType<typeof userEvent.setup>) => {
|
|||
await user.click(screen.getByText("Save Changes"));
|
||||
};
|
||||
|
||||
const renderView = () => render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin />);
|
||||
const renderView = () =>
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin />);
|
||||
|
||||
const lastPayload = () => vi.mocked(networking.updateGuardrailCall).mock.calls.at(-1)?.[2];
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ vi.mock("@/components/networking", () => ({
|
|||
getGuardrailUISettings: vi.fn(),
|
||||
getGuardrailProviderSpecificParams: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
deleteGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ContentFilterManager
|
||||
|
|
@ -65,7 +66,9 @@ describe("Guardrail Info", () => {
|
|||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
// Wait for the loading to complete and data to be rendered
|
||||
await waitFor(() => {
|
||||
|
|
@ -103,7 +106,9 @@ describe("Guardrail Info", () => {
|
|||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("pre_call, post_call (tag-based)")).not.toHaveLength(0);
|
||||
});
|
||||
|
|
@ -131,7 +136,9 @@ describe("Guardrail Info", () => {
|
|||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
const logo = await screen.findByAltText("Presidio PII logo");
|
||||
expect(logo).toHaveAttribute("src", expect.stringContaining("microsoft_azure.svg"));
|
||||
|
|
@ -162,7 +169,7 @@ describe("Guardrail Info", () => {
|
|||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { container } = render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -212,7 +219,9 @@ describe("Guardrail Info", () => {
|
|||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("PII Entity Configuration")).toBeInTheDocument();
|
||||
|
|
@ -245,7 +254,9 @@ describe("Guardrail Info", () => {
|
|||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
vi.mocked(networking.updateGuardrailCall).mockResolvedValue({ status: "success" });
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
|
|
@ -331,7 +342,9 @@ describe("Guardrail Info", () => {
|
|||
});
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "false");
|
||||
|
|
@ -355,10 +368,90 @@ describe("Guardrail Info when the guardrail cannot be loaded", () => {
|
|||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<GuardrailInfoView guardrailId="stale-id" onClose={onClose} accessToken="123" isAdmin={true} />);
|
||||
render(
|
||||
<GuardrailInfoView
|
||||
guardrailId="stale-id"
|
||||
onClose={onClose}
|
||||
onDeleted={() => {}}
|
||||
accessToken="123"
|
||||
isAdmin={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Guardrail not found")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /back to guardrails/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("Delete from the Settings card", () => {
|
||||
const dbGuardrail = {
|
||||
guardrail_id: "123",
|
||||
guardrail_name: "Test Guardrail",
|
||||
litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true },
|
||||
guardrail_definition_location: "db",
|
||||
};
|
||||
const uiSettings = {
|
||||
supported_entities: [],
|
||||
supported_actions: [],
|
||||
pii_entity_categories: [],
|
||||
supported_modes: ["pre_call"],
|
||||
};
|
||||
const mockDbGuardrail = () => {
|
||||
vi.mocked(networking.getGuardrailInfo).mockResolvedValue(dbGuardrail);
|
||||
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue(uiSettings);
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
};
|
||||
|
||||
const openDeleteModalAndConfirm = async () => {
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
const modal = within(await screen.findByRole("dialog"));
|
||||
expect(modal.getByText(/silently stop running it/)).toBeInTheDocument();
|
||||
const confirmButton = modal.getByRole("button", { name: "Delete" });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
fireEvent.change(modal.getByPlaceholderText("Test Guardrail"), { target: { value: "Test Guardrail" } });
|
||||
fireEvent.click(confirmButton);
|
||||
};
|
||||
|
||||
it("deletes the guardrail and calls onDeleted once the admin types its name", async () => {
|
||||
mockDbGuardrail();
|
||||
vi.mocked(networking.deleteGuardrailCall).mockResolvedValue({});
|
||||
const onDeleted = vi.fn();
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={onDeleted} accessToken="tok" isAdmin />,
|
||||
);
|
||||
|
||||
await openDeleteModalAndConfirm();
|
||||
|
||||
await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1));
|
||||
expect(networking.deleteGuardrailCall).toHaveBeenCalledWith("tok", "123");
|
||||
});
|
||||
|
||||
it("hides Delete while the settings are being edited", async () => {
|
||||
mockDbGuardrail();
|
||||
render(<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={() => {}} accessToken="tok" isAdmin />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Edit Settings" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call onDeleted when the delete request fails", async () => {
|
||||
mockDbGuardrail();
|
||||
vi.mocked(networking.deleteGuardrailCall).mockRejectedValue(new Error("boom"));
|
||||
const onDeleted = vi.fn();
|
||||
render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} onDeleted={onDeleted} accessToken="tok" isAdmin />,
|
||||
);
|
||||
|
||||
await openDeleteModalAndConfirm();
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(networking.deleteGuardrailCall).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
} from "./GuardrailFormField";
|
||||
import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager";
|
||||
import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal";
|
||||
import { GuardrailSettingsActions } from "./GuardrailSettingsActions";
|
||||
import { GuardrailModeCard } from "./GuardrailModeDisplay";
|
||||
import { GuardrailReadOnlyDetails } from "./GuardrailReadOnlyDetails";
|
||||
import {
|
||||
|
|
@ -72,11 +73,12 @@ const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||
export interface GuardrailInfoProps {
|
||||
guardrailId: string;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, accessToken, isAdmin }) => {
|
||||
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, onDeleted, accessToken, isAdmin }) => {
|
||||
const [guardrailData, setGuardrailData] = useState<any>(null);
|
||||
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -686,25 +688,21 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
|||
{isAdmin && (
|
||||
<TabsContent value="settings" keepMounted>
|
||||
<Card className="block p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Guardrail Settings</h3>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="mr-auto text-lg font-medium">Guardrail Settings</h3>
|
||||
{isConfigGuardrail && (
|
||||
<SimpleTooltip content="Guardrail is defined in the config file and cannot be edited.">
|
||||
<Info role="img" aria-label="Config guardrail details" className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{!isEditing &&
|
||||
!isConfigGuardrail &&
|
||||
(guardrailData.litellm_params?.guardrail === "custom_code" ? (
|
||||
<Button variant="outline" onClick={() => setCustomCodeModalVisible(true)}>
|
||||
<Code />
|
||||
Edit Code
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsEditing(true)}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
))}
|
||||
<GuardrailSettingsActions
|
||||
guardrail={guardrailData}
|
||||
isEditing={isEditing}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onEditCode={() => setCustomCodeModalVisible(true)}
|
||||
accessToken={accessToken}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue