-
Global (All Providers)
+
+
+
+
+ {labelWithHint(
+ "Provider",
+ "Select 'Global' to apply margin to all providers, or select a specific provider",
+ )}
+
+ onProviderChange(option?.value)}
+ itemToStringLabel={(option: ProviderOption) => option.label}
+ isItemEqualToValue={(option: ProviderOption, selected: ProviderOption) => option.value === selected.value}
+ >
+
+
+ No matching providers
+
+ {(option: ProviderOption) => (
+
+
+ {option.providerEnum !== null && (
+
+ )}
+ {option.label}
+
+
+ )}
+
+
+
+
+
+
+
+ {labelWithHint("Margin Type", "Choose how to apply the margin: percentage-based or fixed amount")}
+
+ onMarginTypeChange(value as "percentage" | "fixed")}
+ className="w-full"
+ >
+
+
+ Percentage-based
+
+
+
+ Fixed Amount
+
+
+
+
+ {marginType === "percentage" && (
+
+
+ {labelWithHint("Margin Percentage", "Enter a percentage value (e.g., 10 for 10% margin)")}
+
+
+ onPercentageChange(event.target.value)}
+ className="rounded-lg flex-1"
+ />
+ %
-
- {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
- const providerValue = provider_map[providerEnum as keyof typeof provider_map];
- // Only show providers that don't already have a margin configured
- if (providerValue && marginConfig[providerValue]) {
- return null;
+
+ )}
+
+ {marginType === "fixed" && (
+
+
+ {labelWithHint("Fixed Margin Amount", "Enter a fixed amount in USD (e.g., 0.001 for $0.001 per request)")}
+
+
+ $
+ onFixedAmountChange(event.target.value)}
+ className="rounded-lg flex-1"
+ />
+
+
+ )}
+
+
+
-
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/TokenSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/TokenSelect.tsx
new file mode 100644
index 00000000000..fb1418d02b9
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/TokenSelect.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import * as React from "react";
+
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxValue,
+ useComboboxAnchor,
+} from "@/components/ui/combobox";
+
+interface TokenSelectProps {
+ id: string;
+ value: readonly string[] | undefined;
+ onValueChange: (value: string[]) => void;
+ onBlur?: () => void;
+ placeholder: string;
+ options?: readonly string[];
+ allowCustomValues?: boolean;
+ tokenSeparators?: readonly string[];
+ emptyText?: string;
+ ariaInvalid?: true;
+ ariaDescribedBy?: string;
+}
+
+const splitOnSeparators = (text: string, separators: readonly string[]): string[] =>
+ separators.reduce
((parts, separator) => parts.flatMap((part) => part.split(separator)), [text]);
+
+const withAdditions = (current: readonly string[], additions: readonly string[]): string[] => [
+ ...current,
+ ...additions.filter((addition) => addition !== "" && !current.includes(addition)),
+];
+
+export const includesQuery = (item: string, query: string): boolean => item.toLowerCase().includes(query.toLowerCase());
+
+export const TokenSelect: React.FC = ({
+ id,
+ value,
+ onValueChange,
+ onBlur,
+ placeholder,
+ options,
+ allowCustomValues = false,
+ tokenSeparators = [],
+ emptyText = "No options found",
+ ariaInvalid,
+ ariaDescribedBy,
+}) => {
+ const anchor = useComboboxAnchor();
+ const [query, setQuery] = React.useState("");
+ const selected = value ?? [];
+ const showDropdown = options !== undefined;
+
+ const pendingCustomValue = allowCustomValues && query.trim() !== "" && !options?.includes(query.trim());
+ const items = pendingCustomValue ? [...(options ?? []), query.trim()] : options ?? [];
+
+ const handleInputValueChange = (next: string) => {
+ if (!allowCustomValues || !tokenSeparators.some((separator) => next.includes(separator))) {
+ setQuery(next);
+ return;
+ }
+ const parts = splitOnSeparators(next, tokenSeparators);
+ const committed = parts.slice(0, -1).map((part) => part.trim());
+ onValueChange(withAdditions(selected, committed));
+ setQuery(parts[parts.length - 1]);
+ };
+
+ const handleBlur = () => {
+ const pending = query.trim();
+ if (allowCustomValues && pending !== "") {
+ onValueChange(withAdditions(selected, [pending]));
+ }
+ setQuery("");
+ onBlur?.();
+ };
+
+ return (
+ {
+ onValueChange(next);
+ setQuery("");
+ }}
+ inputValue={query}
+ onInputValueChange={handleInputValueChange}
+ filter={includesQuery}
+ >
+ }>
+
+ {(chips: string[]) => (
+ <>
+ {chips.map((chip) => (
+
+ {chip}
+
+ ))}
+
+ >
+ )}
+
+
+ {showDropdown && (
+
+ {emptyText}
+
+ {(item: string) => (
+
+ {item}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
index 73b58be1179..cdb8c7ffab9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
@@ -1,15 +1,23 @@
import React, { useState, useEffect } from "react";
-import { Modal, Form, Select, Radio, Divider, Typography } from "antd";
-import { Button } from "@tremor/react";
+import { Modal } from "antd";
+import { CircleHelp } from "lucide-react";
+import { z } from "zod/v4";
import { Policy } from "@/components/policies/types";
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { FieldGroup, FieldLabel, FieldTitle } from "@/components/shared/form/field";
+import { FormField } from "@/components/shared/form/FormField";
+import { Button } from "@/components/ui/button";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Separator } from "@/components/ui/separator";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
+import { useZodForm } from "@/lib/forms/useZodForm";
import { buildAttachmentData } from "./build_attachment_data";
import { getInvalidTeamEntries } from "./scope_validation";
import ImpactPreviewAlert from "./impact_preview_alert";
-
-const { Text } = Typography;
+import { TokenSelect } from "./TokenSelect";
interface AddAttachmentFormProps {
visible: boolean;
@@ -20,6 +28,60 @@ interface AddAttachmentFormProps {
createAttachment: (accessToken: string, attachmentData: any) => Promise;
}
+type ScopeType = "global" | "specific";
+
+interface AttachmentFormValues {
+ policy_names: string[];
+ teams: string[];
+ keys: string[];
+ models: string[];
+ tags: string[];
+}
+
+const EMPTY_VALUES: AttachmentFormValues = {
+ policy_names: [],
+ teams: [],
+ keys: [],
+ models: [],
+ tags: [],
+};
+
+const attachmentShape = {
+ policy_names: z.array(z.string()).min(1, "Please select at least one policy"),
+ teams: z.array(z.string()),
+ keys: z.array(z.string()),
+ models: z.array(z.string()),
+ tags: z.array(z.string()),
+};
+
+const buildAttachmentSchema = (scopeType: ScopeType, teamsLoaded: boolean, availableTeams: string[]) =>
+ z.object(attachmentShape).superRefine((values, ctx) => {
+ if (scopeType !== "specific" || !teamsLoaded) {
+ return;
+ }
+ const invalid = getInvalidTeamEntries(values.teams, availableTeams);
+ if (invalid.length === 0) {
+ return;
+ }
+ ctx.addIssue({
+ code: "custom",
+ path: ["teams"],
+ message:
+ `These teams don't exist: ${invalid.join(", ")}. ` +
+ `Choose an existing team, or use a wildcard like "team-*" to match by prefix.`,
+ });
+ });
+
+const labelWithHint = (label: string, hint: string): React.ReactNode => (
+ <>
+ {label}
+
+ } />
+ {hint}
+
+ >
+);
+
const AddAttachmentForm: React.FC = ({
visible,
onClose,
@@ -28,9 +90,8 @@ const AddAttachmentForm: React.FC = ({
policies,
createAttachment,
}) => {
- const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
- const [scopeType, setScopeType] = useState<"global" | "specific">("global");
+ const [scopeType, setScopeType] = useState("global");
const [availableTeams, setAvailableTeams] = useState([]);
const [teamsLoaded, setTeamsLoaded] = useState(false);
const [availableKeys, setAvailableKeys] = useState([]);
@@ -41,6 +102,9 @@ const AddAttachmentForm: React.FC = ({
const [isEstimating, setIsEstimating] = useState(false);
const [impactResult, setImpactResult] = useState(null);
const { userId, userRole } = useAuthorized();
+ const form = useZodForm(buildAttachmentSchema(scopeType, teamsLoaded, availableTeams), {
+ defaultValues: EMPTY_VALUES,
+ });
useEffect(() => {
if (visible && accessToken) {
@@ -95,30 +159,22 @@ const AddAttachmentForm: React.FC = ({
};
const resetForm = () => {
- form.resetFields();
+ form.reset(EMPTY_VALUES);
setScopeType("global");
setImpactResult(null);
};
const handlePreviewImpact = async () => {
if (!accessToken) return;
- try {
- await form.validateFields(["policy_names"]);
- } catch {
+ if (!(await form.trigger("policy_names"))) {
return;
}
setIsEstimating(true);
try {
- const { policy_names = [] } = form.getFieldsValue(true);
- const firstPolicy = policy_names?.[0];
+ const values = form.getValues();
+ const firstPolicy = values.policy_names[0];
if (!firstPolicy) return;
- const data = buildAttachmentData(
- {
- ...form.getFieldsValue(true),
- policy_name: firstPolicy,
- },
- scopeType,
- );
+ const data = buildAttachmentData({ ...values, policy_name: firstPolicy }, scopeType);
const result = await estimateAttachmentImpactCall(accessToken, data);
setImpactResult(result);
} catch (error) {
@@ -133,27 +189,17 @@ const AddAttachmentForm: React.FC = ({
onClose();
};
- const handleSubmit = async () => {
+ const handleSubmit = async (values: AttachmentFormValues) => {
try {
setIsSubmitting(true);
- await form.validateFields();
if (!accessToken) {
throw new Error("No access token available");
}
- const values = form.getFieldsValue(true);
- const selectedPolicyNames: string[] = values.policy_names || [];
-
const results = await Promise.allSettled(
- selectedPolicyNames.map((policyName) => {
- const data = buildAttachmentData(
- {
- ...values,
- policy_name: policyName,
- },
- scopeType,
- );
+ values.policy_names.map((policyName) => {
+ const data = buildAttachmentData({ ...values, policy_name: policyName }, scopeType);
return createAttachment(accessToken, data);
}),
);
@@ -182,165 +228,207 @@ const AddAttachmentForm: React.FC = ({
}
};
- const policyOptions = policies.map((p) => ({
- label: p.policy_name,
- value: p.policy_name,
- }));
+ const policyOptions = policies.map((p) => p.policy_name);
return (
-
-
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx
new file mode 100644
index 00000000000..c8779be06c4
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx
@@ -0,0 +1,149 @@
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
+import { renderWithProviders } from "@/../tests/test-utils";
+import * as networking from "@/components/networking";
+import PolicyTestPanel from "./policy_test_panel";
+
+vi.mock("@/components/networking");
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({ userId: "admin-user-id", userRole: "Admin", accessToken: "test-token" }),
+}));
+
+const RESOLVED = {
+ effective_guardrails: ["pii-masking"],
+ matched_policies: [{ policy_name: "policy-alpha", matched_via: "team_alias", guardrails_added: ["pii-masking"] }],
+};
+
+const setup = () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ renderWithProviders();
+ return user;
+};
+
+const pickOption = async (user: ReturnType, label: string, option: string) => {
+ await user.click(screen.getByLabelText(label));
+ await user.click(await screen.findByTitle(option));
+};
+
+const simulate = async (user: ReturnType) => {
+ await user.click(screen.getByRole("button", { name: "Simulate" }));
+};
+
+describe("PolicyTestPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(networking.teamListCall).mockResolvedValue([
+ { team_alias: "team-a" },
+ { team_alias: "team-b" },
+ ] as unknown as Awaited>);
+ vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [{ key_alias: "key-a" }] } as unknown as Awaited<
+ ReturnType
+ >);
+ vi.mocked(networking.modelAvailableCall).mockResolvedValue({ data: [{ id: "gpt-4" }] } as unknown as Awaited<
+ ReturnType
+ >);
+ vi.mocked(networking.resolvePoliciesCall).mockResolvedValue(RESOLVED);
+ });
+
+ it("loads teams, keys and models for the dropdowns", async () => {
+ setup();
+ await waitFor(() => expect(networking.teamListCall).toHaveBeenCalledWith("test-token", null, "admin-user-id"));
+ expect(networking.keyListCall).toHaveBeenCalledWith("test-token", null, null, null, null, null, 1, 100);
+ expect(networking.modelAvailableCall).toHaveBeenCalledWith("test-token", "admin-user-id", "Admin");
+ });
+
+ it("sends an empty context when nothing has been picked", async () => {
+ const user = setup();
+ await simulate(user);
+ await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
+ expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", {});
+ });
+
+ it("sends only the fields that were filled in", async () => {
+ const user = setup();
+ await waitFor(() => expect(networking.teamListCall).toHaveBeenCalled());
+ await pickOption(user, "Team Alias", "team-b");
+ await pickOption(user, "Model", "gpt-4");
+
+ await simulate(user);
+
+ await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
+ expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", {
+ team_alias: "team-b",
+ model: "gpt-4",
+ });
+ });
+
+ it("turns each token-separated entry into its own tag", async () => {
+ const user = setup();
+ const tags = screen.getByLabelText("Tags");
+ await user.click(tags);
+ await user.type(tags, "prod-us,");
+ await user.type(tags, "healthcare ");
+
+ await simulate(user);
+
+ await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
+ expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", { tags: ["prod-us", "healthcare"] });
+ });
+
+ it("commits tag text still sitting in the box when the field loses focus", async () => {
+ const user = setup();
+ const tags = screen.getByLabelText("Tags");
+ await user.click(tags);
+ await user.type(tags, "prod-us");
+
+ await simulate(user);
+
+ await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
+ expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", { tags: ["prod-us"] });
+ });
+
+ it("shows the placeholder before the first run, then the results", async () => {
+ const user = setup();
+ expect(screen.getByText("No simulation run yet")).toBeInTheDocument();
+
+ await simulate(user);
+
+ expect(await screen.findByText("Effective Guardrails")).toBeInTheDocument();
+ expect(screen.getByText("Matched Policies")).toBeInTheDocument();
+ expect(screen.getByText("policy-alpha")).toBeInTheDocument();
+ expect(screen.getByText("team_alias")).toBeInTheDocument();
+ expect(screen.queryByText("No simulation run yet")).not.toBeInTheDocument();
+ });
+
+ it("reports a failed resolve without results", async () => {
+ vi.mocked(networking.resolvePoliciesCall).mockRejectedValue(new Error("boom"));
+ const user = setup();
+
+ await simulate(user);
+
+ expect(await screen.findByText("Failed to resolve policies. Check the proxy logs.")).toBeInTheDocument();
+ });
+
+ it("clears both the picked context and the results on Reset", async () => {
+ const user = setup();
+ await waitFor(() => expect(networking.teamListCall).toHaveBeenCalled());
+ await pickOption(user, "Team Alias", "team-a");
+ await simulate(user);
+ await screen.findByText("Effective Guardrails");
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+
+ expect(screen.getByText("No simulation run yet")).toBeInTheDocument();
+ await simulate(user);
+ await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(2));
+ expect(vi.mocked(networking.resolvePoliciesCall).mock.calls[1][1]).toEqual({});
+ });
+
+ it("does not resolve anything without an access token", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ renderWithProviders();
+ expect(networking.teamListCall).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Simulate" }));
+ expect(networking.resolvePoliciesCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
index 8b3d623655b..82cb3932e21 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
@@ -1,10 +1,22 @@
import React, { useState, useEffect } from "react";
-import { Form, Select, Alert, Tag, Empty, Typography } from "antd";
-import { Button } from "@tremor/react";
+import { useForm } from "react-hook-form";
+import { Alert, Empty } from "antd";
import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
-
-const { Text } = Typography;
+import { FieldGroup } from "@/components/shared/form/field";
+import { FormField } from "@/components/shared/form/FormField";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
+import { TokenSelect, includesQuery } from "./TokenSelect";
interface PolicyTestPanelProps {
accessToken: string | null;
@@ -21,8 +33,65 @@ interface ResolveResult {
matched_policies: PolicyMatchDetail[];
}
+interface PolicyTestFormValues {
+ team_alias: string | undefined;
+ key_alias: string | undefined;
+ model: string | undefined;
+ tags: string[] | undefined;
+}
+
+interface ResolveContext {
+ team_alias?: string;
+ key_alias?: string;
+ model?: string;
+ tags?: string[];
+}
+
+const EMPTY_VALUES: PolicyTestFormValues = {
+ team_alias: undefined,
+ key_alias: undefined,
+ model: undefined,
+ tags: undefined,
+};
+
+const buildResolveContext = (values: PolicyTestFormValues): ResolveContext => ({
+ ...(values.team_alias ? { team_alias: values.team_alias } : {}),
+ ...(values.key_alias ? { key_alias: values.key_alias } : {}),
+ ...(values.model ? { model: values.model } : {}),
+ ...(values.tags && values.tags.length > 0 ? { tags: values.tags } : {}),
+});
+
+interface ContextComboboxProps {
+ id: string;
+ value: string | undefined;
+ onChange: (value: string | undefined) => void;
+ placeholder: string;
+ options: string[];
+}
+
+const ContextCombobox: React.FC = ({ id, value, onChange, placeholder, options }) => (
+ onChange(next ?? undefined)}
+ filter={includesQuery}
+ >
+
+
+ No options found
+
+ {(item: string) => (
+
+ {item}
+
+ )}
+
+
+
+);
+
const PolicyTestPanel: React.FC = ({ accessToken }) => {
- const [form] = Form.useForm();
+ const form = useForm({ defaultValues: EMPTY_VALUES });
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState(null);
const [hasSearched, setHasSearched] = useState(false);
@@ -72,14 +141,7 @@ const PolicyTestPanel: React.FC = ({ accessToken }) => {
setIsLoading(true);
setHasSearched(true);
try {
- const values = form.getFieldsValue(true);
- const context: any = {};
- if (values.team_alias) context.team_alias = values.team_alias;
- if (values.key_alias) context.key_alias = values.key_alias;
- if (values.model) context.model = values.model;
- if (values.tags && values.tags.length > 0) context.tags = values.tags;
-
- const data = await resolvePoliciesCall(accessToken, context);
+ const data = await resolvePoliciesCall(accessToken, buildResolveContext(form.getValues()));
setResult(data);
} catch (error) {
console.error("Error resolving policies:", error);
@@ -90,76 +152,86 @@ const PolicyTestPanel: React.FC = ({ accessToken }) => {
};
const handleReset = () => {
- form.resetFields();
+ form.reset(EMPTY_VALUES);
setResult(null);
setHasSearched(false);
};
return (
-
+
Policy Simulator
-
+
Simulate a request to see which policies and guardrails would apply. Select a team, key, model, or tags
below and click "Simulate" to see the results.
-
+
-