From ef38c665ca387aa194513ba3ff7bfa9d788a4498 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 11 Apr 2026 17:35:50 -0700 Subject: [PATCH 01/13] fix(guardrails): use plural key in get_disable_global_guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename disable_global_guardrail → disable_global_guardrails to match the key name used by litellm_pre_call_utils.py, the API endpoints, and the UI when propagating key/team metadata. The singular form was introduced in PR #16983 and has never matched the plural form written by the rest of the codebase, so the feature silently did nothing. Re-applies fix originally from #25488. Original commit could not be merged due to missing signature. Co-Authored-By: Remi Mabon --- litellm/integrations/custom_guardrail.py | 8 ++++---- tests/test_litellm/integrations/test_custom_guardrail.py | 8 ++++---- .../guardrails/guardrail_hooks/test_panw_prisma_airs.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index aa2a8121ee..c95010e7f3 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -259,11 +259,11 @@ class CustomGuardrail(CustomLogger): """ Returns True if the global guardrail should be disabled """ - if "disable_global_guardrail" in data: - return data["disable_global_guardrail"] + if "disable_global_guardrails" in data: + return data["disable_global_guardrails"] metadata = data.get("litellm_metadata") or data.get("metadata", {}) - if "disable_global_guardrail" in metadata: - return metadata["disable_global_guardrail"] + if "disable_global_guardrails" in metadata: + return metadata["disable_global_guardrails"] return False def _is_valid_response_type(self, result: Any) -> bool: diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 7c60cbb52e..abbb572826 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -197,7 +197,7 @@ class TestCustomGuardrailShouldRunGuardrail: data_with_disable_root = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}], - "disable_global_guardrail": True, + "disable_global_guardrails": True, } result = custom_guardrail.should_run_guardrail( data=data_with_disable_root, event_type=GuardrailEventHooks.pre_call @@ -210,7 +210,7 @@ class TestCustomGuardrailShouldRunGuardrail: data_with_disable_litellm = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}], - "litellm_metadata": {"disable_global_guardrail": True}, + "litellm_metadata": {"disable_global_guardrails": True}, } result = custom_guardrail.should_run_guardrail( data=data_with_disable_litellm, event_type=GuardrailEventHooks.pre_call @@ -223,7 +223,7 @@ class TestCustomGuardrailShouldRunGuardrail: data_with_disable_metadata = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}], - "metadata": {"disable_global_guardrail": True}, + "metadata": {"disable_global_guardrails": True}, } result = custom_guardrail.should_run_guardrail( data=data_with_disable_metadata, event_type=GuardrailEventHooks.pre_call @@ -236,7 +236,7 @@ class TestCustomGuardrailShouldRunGuardrail: data_with_disable_false = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}], - "disable_global_guardrail": False, + "disable_global_guardrails": False, } result = custom_guardrail.should_run_guardrail( data=data_with_disable_false, event_type=GuardrailEventHooks.pre_call diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index 7486f602dd..ace3901b37 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -2263,7 +2263,7 @@ class TestPanwAirsMcpForceRun: "test_panw_airs", False, "pre_call", - _simple_data(disable_global_guardrail=True), + _simple_data(disable_global_guardrails=True), GuardrailEventHooks.pre_mcp_call, False, id="honors_disable_global_on_mcp_hooks", From 88beed905ea9a37d2ae67a021a754936500ff12b Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 11 Apr 2026 13:59:59 -0700 Subject: [PATCH 02/13] feat(guardrails): per-team opt-out for specific global guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add disabled_global_guardrails list field to team metadata that selectively skips named globals at request time. Coexists with the existing disable_global_guardrails boolean kill switch — the boolean kills all globals (including future ones), the new list selectively skips named ones (new globals auto-apply). The field lives in the team metadata JSON column; no schema migration. - litellm/proxy/litellm_pre_call_utils.py: propagate the field from team_metadata into per-request data.metadata - litellm/integrations/custom_guardrail.py: new get_disabled_global_guardrails_from_metadata helper plus a scoped-to-globals early return at the top of should_run_guardrail --- litellm/integrations/custom_guardrail.py | 13 +++++++++++++ litellm/proxy/litellm_pre_call_utils.py | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index c95010e7f3..fafa084d21 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -266,6 +266,15 @@ class CustomGuardrail(CustomLogger): return metadata["disable_global_guardrails"] return False + def get_disabled_global_guardrails_from_metadata(self, data: dict) -> List[str]: + """ + Returns the list of global guardrail names the team/key has opted out of. + """ + if "disabled_global_guardrails" in data: + return data["disabled_global_guardrails"] or [] + metadata = data.get("litellm_metadata") or data.get("metadata", {}) + return metadata.get("disabled_global_guardrails") or [] + def _is_valid_response_type(self, result: Any) -> bool: """ Check if result is a valid LLMResponseTypes instance. @@ -406,6 +415,7 @@ class CustomGuardrail(CustomLogger): """ requested_guardrails = self.get_guardrail_from_metadata(data) disable_global_guardrail = self.get_disable_global_guardrail(data) + disabled_global_guardrails = self.get_disabled_global_guardrails_from_metadata(data) verbose_logger.debug( "inside should_run_guardrail for guardrail=%s event_type= %s guardrail_supported_event_hooks= %s requested_guardrails= %s self.default_on= %s", self.guardrail_name, @@ -414,6 +424,9 @@ class CustomGuardrail(CustomLogger): requested_guardrails, self.default_on, ) + if self.default_on is True and self.guardrail_name in disabled_global_guardrails: + return False + if self.default_on is True and disable_global_guardrail is not True: if self._event_hook_is_event_type(event_type): if isinstance(self.event_hook, Mode): diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 2b8c16ed12..4d893112a3 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1103,6 +1103,12 @@ async def add_litellm_data_to_request( # noqa: PLR0915 data[_metadata_variable_name]["disable_global_guardrails"] = team_metadata[ "disable_global_guardrails" ] + if "disabled_global_guardrails" in team_metadata and isinstance( + team_metadata["disabled_global_guardrails"], list + ): + data[_metadata_variable_name]["disabled_global_guardrails"] = team_metadata[ + "disabled_global_guardrails" + ] if "spend_logs_metadata" in team_metadata and isinstance( team_metadata["spend_logs_metadata"], dict ): From a9d64f8620d76e3ce0e7391c300bd0a37f92575f Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 11 Apr 2026 15:19:50 -0700 Subject: [PATCH 03/13] refactor(ui/guardrails): expose full data from useGuardrails hook Extend useGuardrails to return the full guardrail objects plus derived globalGuardrailNames / optionalGuardrailNames sets via React Query's select option, instead of just an array of names. Update its existing consumer (AddModelForm) to extract names from the new shape. The previous shape was tailored to AddModelForm's single use case (populate a Select with names). The team info per-guardrail opt-out work needs default_on per guardrail to split globals from non-globals, which the old shape couldn't provide. Consolidating into the existing hook gives both consumers one source of truth and one React Query cache entry instead of two parallel fetches. - useGuardrails.ts: rewrite return type, derive global/optional sets in select(); preserve the existing query key and auth-gate semantics - AddModelForm.tsx: extract names from data?.guardrails.map(...) - AddModelForm.test.tsx: update mock to return the new shape (also fixes a pre-existing shape mismatch in the mock) - useGuardrails.test.ts: update 3 assertions to read names via data?.guardrails.map(...) instead of asserting against the flat array --- .../hooks/guardrails/useGuardrails.test.ts | 13 ++--- .../hooks/guardrails/useGuardrails.ts | 48 ++++++++++++++++--- .../add_model/AddModelForm.test.tsx | 6 ++- .../src/components/add_model/AddModelForm.tsx | 3 +- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.test.ts index d9e96a5308..d5b788ab6a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.test.ts @@ -74,7 +74,7 @@ describe("useGuardrails", () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toEqual(expectedGuardrailNames); + expect(result.current.data?.guardrails.map((g) => g.guardrail_name)).toEqual(expectedGuardrailNames); expect(result.current.error).toBeNull(); expect(getGuardrailsList).toHaveBeenCalledWith("test-access-token"); expect(getGuardrailsList).toHaveBeenCalledTimes(1); @@ -228,7 +228,7 @@ describe("useGuardrails", () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toEqual([]); + expect(result.current.data?.guardrails).toEqual([]); expect(getGuardrailsList).toHaveBeenCalledWith("test-access-token"); }); @@ -265,9 +265,10 @@ describe("useGuardrails", () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toEqual(expectedNames); - expect(result.current.data).toHaveLength(2); - expect(result.current.data).toContain("custom-guardrail-1"); - expect(result.current.data).toContain("custom-guardrail-2"); + const names = result.current.data?.guardrails.map((g) => g.guardrail_name); + expect(names).toEqual(expectedNames); + expect(names).toHaveLength(2); + expect(names).toContain("custom-guardrail-1"); + expect(names).toContain("custom-guardrail-2"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts index 9786b7fa35..5c7a8df050 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts @@ -3,16 +3,52 @@ import { createQueryKeys } from "../common/queryKeysFactory"; import { getGuardrailsList } from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface GuardrailListItem { + guardrail_name: string; + litellm_params?: { + default_on?: boolean; + mode?: string | string[]; + [key: string]: unknown; + }; + guardrail_info?: Record | null; + guardrail_id?: string | null; + [key: string]: unknown; +} + +interface GuardrailsListResponse { + guardrails: GuardrailListItem[]; +} + +export interface GuardrailsListData { + guardrails: GuardrailListItem[]; + globalGuardrailNames: Set; + optionalGuardrailNames: Set; +} + +// ── Hook ───────────────────────────────────────────────────────────────────── + const guardrailKeys = createQueryKeys("guardrails"); -export const useGuardrails = (): UseQueryResult => { +export const useGuardrails = (): UseQueryResult => { const { accessToken, userId, userRole } = useAuthorized(); - return useQuery({ + return useQuery({ queryKey: guardrailKeys.list({}), - queryFn: async () => { - const response = await getGuardrailsList(accessToken!); - return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - }, + queryFn: async () => getGuardrailsList(accessToken!), enabled: Boolean(accessToken && userId && userRole), + select: (data) => { + const guardrails: GuardrailListItem[] = data?.guardrails ?? []; + const globalGuardrailNames = new Set(); + const optionalGuardrailNames = new Set(); + for (const g of guardrails) { + if (g.litellm_params?.default_on) { + globalGuardrailNames.add(g.guardrail_name); + } else { + optionalGuardrailNames.add(g.guardrail_name); + } + } + return { guardrails, globalGuardrailNames, optionalGuardrailNames }; + }, }); }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx index de5e138739..4a3dbaedf7 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx @@ -82,7 +82,11 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrails", () => ({ useGuardrails: vi.fn().mockReturnValue({ - data: [{ guardrail_name: "test-guardrail" }], + data: { + guardrails: [{ guardrail_name: "test-guardrail" }], + globalGuardrailNames: new Set(), + optionalGuardrailNames: new Set(["test-guardrail"]), + }, isLoading: false, error: null, }), diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 65e239d58e..cb35a6f414 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -63,7 +63,8 @@ const AddModelForm: React.FC = ({ isLoading: isProviderMetadataLoading, error: providerMetadataError, } = useProviderFields(); - const { data: guardrailsList, isLoading: isGuardrailsLoading, error: guardrailsError } = useGuardrails(); + const { data: guardrailsData, isLoading: isGuardrailsLoading, error: guardrailsError } = useGuardrails(); + const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name); const { data: tagsList, isLoading: isTagsLoading, error: tagsError } = useTags(); const handleTestConnection = async () => { From f9639654fac6c1b1f00330b569f760114a9ffab9 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 11 Apr 2026 15:20:45 -0700 Subject: [PATCH 04/13] feat(ui/team): per-guardrail opt-out for global guardrails Replace the team info page's tags-mode guardrails Select with a grouped multi-select that splits guardrails into "Global" (default_on=true) and "Other" sections. On submit, derive both metadata fields from the single selection array: - metadata.guardrails: non-global opt-ins (additive) - metadata.disabled_global_guardrails: globals the team has opted out of The legacy disable_global_guardrails kill-switch toggle stays alongside the multiselect and remains semantically distinct: it kills all global guardrails including any added in the future, while the new list lets new globals auto-apply unless explicitly excluded. Other changes: - Switch from a manual useEffect/useState guardrails fetch to the consolidated useGuardrails hook - Replace stale "Select existing guardrails or enter new ones" help text with a single tooltip on each section's info icon - Render selected chips with green/blue color coding via tagRender so global vs non-global is visible without opening the dropdown - Drop the redundant [Global] tag from inside the OptGroup options (the group label already conveys it) - Update the read-only display to show the effective set (globals not opted out + additive opt-ins) with [Global] suffix on globals --- .../src/components/team/TeamInfo.tsx | 103 ++++++++++++------ 1 file changed, 72 insertions(+), 31 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 6308be6586..edc95aff21 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -2,7 +2,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import UserSearchModal from "@/components/common_components/user_search_modal"; import { - getGuardrailsList, getPoliciesList, getPolicyInfoWithGuardrails, Member, @@ -14,13 +13,14 @@ import { teamMemberUpdateCall, teamUpdateCall, } from "@/components/networking"; +import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { isProxyAdminRole } from "@/utils/roles"; import { EditOutlined, InfoCircleOutlined, MinusCircleOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tooltip } from "antd"; +import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tag, Tooltip } from "antd"; import MessageManager from "@/components/molecules/message_manager"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; @@ -179,7 +179,8 @@ const TeamInfoView: React.FC = ({ const [mcpAccessGroups, setMcpAccessGroups] = useState([]); const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); const [copiedStates, setCopiedStates] = useState>({}); - const [guardrailsList, setGuardrailsList] = useState([]); + const { data: guardrailsData } = useGuardrails(); + const globalGuardrailNames = guardrailsData?.globalGuardrailNames ?? new Set(); const [policiesList, setPoliciesList] = useState([]); const [policyGuardrails, setPolicyGuardrails] = useState>({}); const [loadingPolicies, setLoadingPolicies] = useState(false); @@ -273,17 +274,6 @@ const TeamInfoView: React.FC = ({ }; useEffect(() => { - const fetchGuardrails = async () => { - try { - if (!accessToken) return; - const response = await getGuardrailsList(accessToken); - const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - setGuardrailsList(guardrailNames); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - } - }; - const fetchPolicies = async () => { try { if (!accessToken) return; @@ -295,7 +285,6 @@ const TeamInfoView: React.FC = ({ } }; - fetchGuardrails(); fetchPolicies(); }, [accessToken]); @@ -504,7 +493,10 @@ const TeamInfoView: React.FC = ({ budget_duration: values.budget_duration, metadata: { ...parsedMetadata, - ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), + guardrails: (values.guardrails || []).filter((n: string) => !globalGuardrailNames.has(n)), + disabled_global_guardrails: Array.from(globalGuardrailNames).filter( + (n) => !(values.guardrails || []).includes(n), + ), ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), disable_global_guardrails: values.disable_global_guardrails || false, soft_budget_alerting_emails: @@ -610,6 +602,29 @@ const TeamInfoView: React.FC = ({ const { team_info: info } = teamData; + const optedOutGlobals = new Set(info.metadata?.disabled_global_guardrails || []); + const effectiveGuardrails = [ + ...Array.from(globalGuardrailNames).filter((n) => !optedOutGlobals.has(n)), + ...(info.metadata?.guardrails || []), + ]; + + const preventTagMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const renderGuardrailTag = ({ label, value, closable, onClose }: any) => ( + + {label} + + ); + const copyToClipboard = async (text: string, key: string) => { const success = await utilCopyToClipboard(text); if (success) { @@ -739,11 +754,12 @@ const TeamInfoView: React.FC = ({ Guardrails - {info.guardrails && info.guardrails.length > 0 ? ( + {effectiveGuardrails.length > 0 ? (
- {info.guardrails.map((guardrail: string, index: number) => ( - - {guardrail} + {effectiveGuardrails.map((name) => ( + + {name} + {globalGuardrailNames.has(name) ? " · Global" : ""} ))}
@@ -866,7 +882,7 @@ const TeamInfoView: React.FC = ({ team_member_rpm_limit: info.team_member_budget_table?.rpm_limit, team_member_budget: info.team_member_budget_table?.max_budget, team_member_budget_duration: info.team_member_budget_table?.budget_duration, - guardrails: info.metadata?.guardrails || [], + guardrails: effectiveGuardrails, policies: info.policies || [], disable_global_guardrails: info.metadata?.disable_global_guardrails || false, soft_budget_alerting_emails: @@ -1090,7 +1106,7 @@ const TeamInfoView: React.FC = ({ label={ Guardrails{" "} - + = ({ } name="guardrails" - help="Select existing guardrails or enter new ones" > - Disable Global Guardrails - + Disable all global guardrails{" "} + } name="disable_global_guardrails" valuePropName="checked" - help="Bypass global guardrails for this team" > @@ -1145,7 +1187,6 @@ const TeamInfoView: React.FC = ({ } name="policies" - help="Select existing policies or enter new ones" >