mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-10 13:03:30 +00:00
Merge pull request #25575 from BerriAI/litellm_feat-per-guardrail-opt-out-for-global-guardrails
feat(guardrails): per-team opt-out for specific global guardrails
This commit is contained in:
@@ -51,7 +51,9 @@ LiteLLM is a unified interface for 100+ LLMs that:
|
||||
|
||||
### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND)
|
||||
|
||||
1. **Tremor is DEPRECATED, do not use Tremor components in new features/changes**
|
||||
1. **Always use `antd` for new UI components — Tremor is DEPRECATED**
|
||||
- We are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file.
|
||||
- Use `antd` equivalents: `Tag` for labels, plain `<span>`/`<div>` with Tailwind classes (or `Typography.Text`) for text, `Card` from `antd`, etc. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
|
||||
- The only exception is the Tremor Table component and its required Tremor Table sub components.
|
||||
|
||||
2. **Use Common Components as much as possible**:
|
||||
|
||||
@@ -109,6 +109,9 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
|
||||
### UI / Backend Consistency
|
||||
- When wiring a new UI entity type to an existing backend endpoint, verify the backend API contract (single value vs. array, required vs. optional params) and ensure the UI controls match — e.g., use a single-select dropdown when the backend accepts a single value, not a multi-select
|
||||
|
||||
### UI Component Library
|
||||
- **Always use `antd` for new UI components** — we are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file. Use `antd` equivalents: `Tag` for labels, plain `<span>`/`<div>` with Tailwind classes (or `Typography.Text`) for text, `Card` from `antd`, etc. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
|
||||
|
||||
### MCP OAuth / OpenAPI Transport Mapping
|
||||
- `TRANSPORT.OPENAPI` is a UI-only concept. The backend only accepts `"http"`, `"sse"`, or `"stdio"`. Always map it to `"http"` before any API call (including pre-OAuth temp-session calls).
|
||||
- FastAPI validation errors return `detail` as an array of `{loc, msg, type}` objects. Error extractors must handle: array (map `.msg`), string, nested `{error: string}`, and fallback.
|
||||
|
||||
@@ -259,13 +259,24 @@ 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 get_opted_out_global_guardrails_from_metadata(self, data: dict) -> List[str]:
|
||||
"""
|
||||
Returns the list of global guardrail names the team/key has opted out of.
|
||||
"""
|
||||
if "opted_out_global_guardrails" in data:
|
||||
value = data["opted_out_global_guardrails"]
|
||||
return value if isinstance(value, list) else []
|
||||
metadata = data.get("litellm_metadata") or data.get("metadata", {})
|
||||
value = metadata.get("opted_out_global_guardrails")
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
def _is_valid_response_type(self, result: Any) -> bool:
|
||||
"""
|
||||
Check if result is a valid LLMResponseTypes instance.
|
||||
@@ -406,6 +417,7 @@ class CustomGuardrail(CustomLogger):
|
||||
"""
|
||||
requested_guardrails = self.get_guardrail_from_metadata(data)
|
||||
disable_global_guardrail = self.get_disable_global_guardrail(data)
|
||||
opted_out_global_guardrails = self.get_opted_out_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 +426,9 @@ class CustomGuardrail(CustomLogger):
|
||||
requested_guardrails,
|
||||
self.default_on,
|
||||
)
|
||||
if self.default_on is True and self.guardrail_name in opted_out_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):
|
||||
|
||||
@@ -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 "opted_out_global_guardrails" in team_metadata and isinstance(
|
||||
team_metadata["opted_out_global_guardrails"], list
|
||||
):
|
||||
data[_metadata_variable_name]["opted_out_global_guardrails"] = team_metadata[
|
||||
"opted_out_global_guardrails"
|
||||
]
|
||||
if "spend_logs_metadata" in team_metadata and isinstance(
|
||||
team_metadata["spend_logs_metadata"], dict
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -245,6 +245,121 @@ class TestCustomGuardrailShouldRunGuardrail:
|
||||
result is True
|
||||
), "Global guardrail should still run when disable_global_guardrail=False"
|
||||
|
||||
def test_should_run_guardrail_with_opted_out_global_guardrails(self):
|
||||
"""Test the per-guardrail opt-out list for global (default_on=True) guardrails"""
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
custom_guardrail = CustomGuardrail(
|
||||
guardrail_name="global_guardrail",
|
||||
default_on=True,
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
)
|
||||
|
||||
# Test 1: guardrail in the opt-out list at root level → skipped
|
||||
data_root = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"opted_out_global_guardrails": ["global_guardrail"],
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_root, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
# Test 2: guardrail in the opt-out list inside litellm_metadata → skipped
|
||||
data_litellm = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"litellm_metadata": {"opted_out_global_guardrails": ["global_guardrail"]},
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_litellm, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
# Test 3: guardrail in the opt-out list inside metadata → skipped
|
||||
data_metadata = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"metadata": {"opted_out_global_guardrails": ["global_guardrail"]},
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_metadata, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
# Test 4: a different guardrail in the opt-out list → still runs
|
||||
data_other = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"metadata": {"opted_out_global_guardrails": ["some_other_guardrail"]},
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_other, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Test 5: empty opt-out list → still runs
|
||||
data_empty = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"metadata": {"opted_out_global_guardrails": []},
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_empty, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Test 6: malformed value (bool instead of list) → safely ignored, guardrail runs
|
||||
data_malformed = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"metadata": {"opted_out_global_guardrails": True},
|
||||
}
|
||||
assert (
|
||||
custom_guardrail.should_run_guardrail(
|
||||
data=data_malformed, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_should_run_guardrail_opt_out_does_not_affect_non_global(self):
|
||||
"""Opt-out list only matters for default_on=True guardrails"""
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
non_global = CustomGuardrail(
|
||||
guardrail_name="opt_in_guardrail",
|
||||
default_on=False,
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
)
|
||||
|
||||
# An opt-in guardrail named in opted_out_global_guardrails is still controlled
|
||||
# by the explicit `guardrails` request list, not by the global opt-out list.
|
||||
data = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"metadata": {
|
||||
"opted_out_global_guardrails": ["opt_in_guardrail"],
|
||||
"guardrails": ["opt_in_guardrail"],
|
||||
},
|
||||
}
|
||||
assert (
|
||||
non_global.should_run_guardrail(
|
||||
data=data, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
class TestApplyGuardrailCheck:
|
||||
def test_apply_guardrail_check_only_on_direct_implementation(self):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,35 @@ 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");
|
||||
});
|
||||
|
||||
it("should partition guardrails into global and optional sets based on default_on", async () => {
|
||||
const mockMixedResponse = {
|
||||
guardrails: [
|
||||
{ guardrail_name: "global-guard-a", litellm_params: { default_on: true } },
|
||||
{ guardrail_name: "global-guard-b", litellm_params: { default_on: true } },
|
||||
{ guardrail_name: "optional-guard-a", litellm_params: { default_on: false } },
|
||||
{ guardrail_name: "optional-guard-b" },
|
||||
],
|
||||
};
|
||||
(getGuardrailsList as any).mockResolvedValue(mockMixedResponse);
|
||||
|
||||
const { result } = renderHook(() => useGuardrails(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data?.globalGuardrailNames).toEqual(
|
||||
new Set(["global-guard-a", "global-guard-b"]),
|
||||
);
|
||||
expect(result.current.data?.optionalGuardrailNames).toEqual(
|
||||
new Set(["optional-guard-a", "optional-guard-b"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
guardrail_id?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GuardrailsListResponse {
|
||||
guardrails: GuardrailListItem[];
|
||||
}
|
||||
|
||||
export interface GuardrailsListData {
|
||||
guardrails: GuardrailListItem[];
|
||||
globalGuardrailNames: Set<string>;
|
||||
optionalGuardrailNames: Set<string>;
|
||||
}
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const guardrailKeys = createQueryKeys("guardrails");
|
||||
|
||||
export const useGuardrails = (): UseQueryResult<string[]> => {
|
||||
export const useGuardrails = (): UseQueryResult<GuardrailsListData> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
return useQuery<string[]>({
|
||||
return useQuery<GuardrailsListResponse, Error, GuardrailsListData>({
|
||||
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<string>();
|
||||
const optionalGuardrailNames = new Set<string>();
|
||||
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 };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { Tag } from "antd";
|
||||
import { GlobalOutlined } from "@ant-design/icons";
|
||||
|
||||
interface GuardrailSettingsViewProps {
|
||||
globalGuardrailNames: Set<string>;
|
||||
teamGuardrails?: string[];
|
||||
optedOutGlobalGuardrails?: string[];
|
||||
killSwitchOn?: boolean;
|
||||
variant?: "card" | "inline";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GuardrailSettingsView({
|
||||
globalGuardrailNames,
|
||||
teamGuardrails = [],
|
||||
optedOutGlobalGuardrails = [],
|
||||
killSwitchOn = false,
|
||||
variant = "card",
|
||||
className = "",
|
||||
}: GuardrailSettingsViewProps) {
|
||||
const optedOutSet = new Set(optedOutGlobalGuardrails);
|
||||
const globalsRunning = Array.from(globalGuardrailNames).filter(
|
||||
(n) => !optedOutSet.has(n),
|
||||
);
|
||||
const nonGlobalOptIns = teamGuardrails.filter(
|
||||
(n) => !globalGuardrailNames.has(n),
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
!killSwitchOn && globalsRunning.length === 0 && nonGlobalOptIns.length === 0;
|
||||
|
||||
const content = isEmpty ? (
|
||||
<span className="block text-gray-500">No guardrails configured</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<GlobalOutlined style={{ marginInlineEnd: 4 }} aria-label="Global guardrail" />
|
||||
Global
|
||||
</span>
|
||||
{killSwitchOn ? (
|
||||
<Tag color="gold">Bypassed for this team</Tag>
|
||||
) : globalsRunning.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{globalsRunning.map((name) => (
|
||||
<Tag key={name} color="blue">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="block text-sm text-gray-500">None configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700 mb-2">Team-specific</span>
|
||||
{nonGlobalOptIns.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nonGlobalOptIns.map((name) => (
|
||||
<Tag key={name} color="blue">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="block text-sm text-gray-500">None configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg p-6 ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div>
|
||||
<span className="block font-semibold text-gray-900">Guardrails Settings</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
Global and team-specific guardrails applied to this team
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<span className="block font-medium text-gray-900 mb-3">Guardrails Settings</span>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuardrailSettingsView;
|
||||
@@ -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<string>(),
|
||||
optionalGuardrailNames: new Set<string>(["test-guardrail"]),
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,8 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
||||
isLoading: isProviderMetadataLoading,
|
||||
error: providerMetadataError,
|
||||
} = useProviderFields();
|
||||
const { data: guardrailsList, isLoading: isGuardrailsLoading, error: guardrailsError } = useGuardrails();
|
||||
const { data: guardrailsData } = useGuardrails();
|
||||
const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name);
|
||||
const { data: tagsList, isLoading: isTagsLoading, error: tagsError } = useTags();
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
|
||||
@@ -276,15 +276,17 @@ describe("TeamInfoView", () => {
|
||||
it("should display guardrails in overview when present", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
guardrails: ["guardrail1", "guardrail2"],
|
||||
metadata: { guardrails: ["guardrail1", "guardrail2"] },
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Guardrails")).toBeInTheDocument();
|
||||
expect(screen.getByText("Guardrails Settings")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("guardrail1")).toBeInTheDocument();
|
||||
expect(screen.getByText("guardrail2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display policies in overview when present", async () => {
|
||||
|
||||
@@ -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 { EditOutlined, GlobalOutlined, 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";
|
||||
@@ -31,6 +31,7 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
import DurationSelect from "../common_components/DurationSelect";
|
||||
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
|
||||
import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import GuardrailSettingsView from "../GuardrailSettingsView";
|
||||
import LoggingSettingsView from "../logging_settings_view";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
|
||||
@@ -179,7 +180,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
|
||||
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
||||
const { data: guardrailsData, isLoading: isGuardrailsLoading } = useGuardrails();
|
||||
const globalGuardrailNames = guardrailsData?.globalGuardrailNames ?? new Set<string>();
|
||||
const [policiesList, setPoliciesList] = useState<string[]>([]);
|
||||
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});
|
||||
const [loadingPolicies, setLoadingPolicies] = useState(false);
|
||||
@@ -202,6 +204,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
// Models currently selected in the team edit form, used to scope the per-model
|
||||
// rate limit dropdown to models this team actually has access to.
|
||||
const selectedModelsInForm = Form.useWatch("models", form) as string[] | undefined;
|
||||
const killSwitchOn = Form.useWatch("disable_global_guardrails", form) as boolean | undefined;
|
||||
const availableRateLimitModels = useMemo(() => {
|
||||
const selected = selectedModelsInForm ?? teamData?.team_info?.models ?? [];
|
||||
if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) {
|
||||
@@ -273,17 +276,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
};
|
||||
|
||||
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 +287,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchGuardrails();
|
||||
fetchPolicies();
|
||||
}, [accessToken]);
|
||||
|
||||
@@ -491,6 +482,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const killSwitchOnAtSave = values.disable_global_guardrails === true;
|
||||
const optedOutGlobalGuardrails = killSwitchOnAtSave
|
||||
? Array.from(globalGuardrailNames)
|
||||
: Array.from(globalGuardrailNames).filter(
|
||||
(n) => !(values.guardrails || []).includes(n),
|
||||
);
|
||||
|
||||
const updateData: any = {
|
||||
team_id: teamId,
|
||||
team_alias: values.team_alias,
|
||||
@@ -504,9 +502,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
budget_duration: values.budget_duration,
|
||||
metadata: {
|
||||
...parsedMetadata,
|
||||
...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}),
|
||||
guardrails: (values.guardrails || []).filter((n: string) => !globalGuardrailNames.has(n)),
|
||||
opted_out_global_guardrails: optedOutGlobalGuardrails,
|
||||
...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}),
|
||||
disable_global_guardrails: values.disable_global_guardrails || false,
|
||||
disable_global_guardrails: killSwitchOnAtSave,
|
||||
soft_budget_alerting_emails:
|
||||
typeof values.soft_budget_alerting_emails === "string"
|
||||
? values.soft_budget_alerting_emails
|
||||
@@ -610,6 +609,39 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
|
||||
const { team_info: info } = teamData;
|
||||
|
||||
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
|
||||
const optedOutGlobals = new Set<string>(info.metadata?.opted_out_global_guardrails || []);
|
||||
const nonGlobalOptIns: string[] = (info.metadata?.guardrails || []).filter(
|
||||
(n: string) => !globalGuardrailNames.has(n),
|
||||
);
|
||||
const effectiveGuardrails: string[] = initialKillSwitchOn
|
||||
? nonGlobalOptIns
|
||||
: [
|
||||
...Array.from(globalGuardrailNames).filter((n) => !optedOutGlobals.has(n)),
|
||||
...nonGlobalOptIns,
|
||||
];
|
||||
|
||||
const preventTagMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const renderGuardrailTag = ({ label, value, closable, onClose }: any) => {
|
||||
const isGlobal = globalGuardrailNames.has(value);
|
||||
return (
|
||||
<Tag
|
||||
color="blue"
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
onMouseDown={preventTagMouseDown}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{isGlobal && <GlobalOutlined style={{ marginInlineEnd: 4 }} aria-label="Global guardrail" />}
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, key: string) => {
|
||||
const success = await utilCopyToClipboard(text);
|
||||
if (success) {
|
||||
@@ -738,23 +770,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Text className="font-semibold text-gray-900 mb-3">Guardrails</Text>
|
||||
{info.guardrails && info.guardrails.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.guardrails.map((guardrail: string, index: number) => (
|
||||
<Badge key={index} color="blue">
|
||||
{guardrail}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text className="text-gray-500">No guardrails configured</Text>
|
||||
)}
|
||||
{info.metadata?.disable_global_guardrails && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<Badge color="yellow">Global Guardrails Disabled</Badge>
|
||||
</div>
|
||||
)}
|
||||
<GuardrailSettingsView
|
||||
globalGuardrailNames={globalGuardrailNames}
|
||||
teamGuardrails={info.metadata?.guardrails || []}
|
||||
optedOutGlobalGuardrails={info.metadata?.opted_out_global_guardrails || []}
|
||||
killSwitchOn={initialKillSwitchOn}
|
||||
variant="inline"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -839,10 +861,23 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
{isEditing && isGuardrailsLoading ? (
|
||||
<div className="p-4">Loading...</div>
|
||||
) : isEditing ? (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleTeamUpdate}
|
||||
onValuesChange={(changedValues) => {
|
||||
if ("disable_global_guardrails" in changedValues) {
|
||||
const checked = changedValues.disable_global_guardrails === true;
|
||||
const current = (form.getFieldValue("guardrails") || []) as string[];
|
||||
const nonGlobals = current.filter((n) => !globalGuardrailNames.has(n));
|
||||
form.setFieldValue(
|
||||
"guardrails",
|
||||
checked ? nonGlobals : [...Array.from(globalGuardrailNames), ...nonGlobals],
|
||||
);
|
||||
}
|
||||
}}
|
||||
initialValues={{
|
||||
...info,
|
||||
team_alias: info.team_alias,
|
||||
@@ -866,7 +901,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
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 +1125,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
label={
|
||||
<span>
|
||||
Guardrails{" "}
|
||||
<Tooltip title="Setup your first guardrail">
|
||||
<Tooltip title="Select which guardrails apply to this team. Global guardrails are enabled by default — uncheck to opt out. Other guardrails are opt-in.">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
@@ -1103,27 +1138,61 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
</span>
|
||||
}
|
||||
name="guardrails"
|
||||
help="Select existing guardrails or enter new ones"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Select or enter guardrails"
|
||||
options={guardrailsList.map((name) => ({ value: name, label: name }))}
|
||||
/>
|
||||
mode="multiple"
|
||||
placeholder="Select guardrails"
|
||||
optionLabelProp="label"
|
||||
tagRender={renderGuardrailTag}
|
||||
>
|
||||
<Select.OptGroup
|
||||
label={
|
||||
<>
|
||||
<GlobalOutlined style={{ marginInlineEnd: 4 }} />
|
||||
Global
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(guardrailsData?.guardrails ?? [])
|
||||
.filter((g) => g.litellm_params?.default_on)
|
||||
.map((g) => (
|
||||
<Select.Option
|
||||
key={g.guardrail_name}
|
||||
value={g.guardrail_name}
|
||||
label={g.guardrail_name}
|
||||
disabled={killSwitchOn}
|
||||
>
|
||||
{g.guardrail_name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
<Select.OptGroup label="Other">
|
||||
{(guardrailsData?.guardrails ?? [])
|
||||
.filter((g) => !g.litellm_params?.default_on)
|
||||
.map((g) => (
|
||||
<Select.Option
|
||||
key={g.guardrail_name}
|
||||
value={g.guardrail_name}
|
||||
label={g.guardrail_name}
|
||||
>
|
||||
{g.guardrail_name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Disable Global Guardrails
|
||||
<Tooltip title="When enabled, this team will bypass any guardrails configured to run on every request (global guardrails)">
|
||||
Disable all global guardrails{" "}
|
||||
<Tooltip title="Kill switch: bypass every global guardrail for this team, including any added in the future. For per-guardrail opt-out instead, use the Guardrails dropdown above.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="disable_global_guardrails"
|
||||
valuePropName="checked"
|
||||
help="Bypass global guardrails for this team"
|
||||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
@@ -1145,7 +1214,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
</span>
|
||||
}
|
||||
name="policies"
|
||||
help="Select existing policies or enter new ones"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
@@ -1382,17 +1450,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
<Badge color={info.blocked ? "red" : "green"}>{info.blocked ? "Blocked" : "Active"}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Disable Global Guardrails</Text>
|
||||
<div>
|
||||
{info.metadata?.disable_global_guardrails === true ? (
|
||||
<Badge color="yellow">Enabled - Global guardrails bypassed</Badge>
|
||||
) : (
|
||||
<Badge color="green">Disabled - Global guardrails active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={info.object_permission}
|
||||
variant="inline"
|
||||
@@ -1400,6 +1457,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
<GuardrailSettingsView
|
||||
globalGuardrailNames={globalGuardrailNames}
|
||||
teamGuardrails={info.metadata?.guardrails || []}
|
||||
optedOutGlobalGuardrails={info.metadata?.opted_out_global_guardrails || []}
|
||||
killSwitchOn={initialKillSwitchOn}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
/>
|
||||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={info.metadata?.logging || []}
|
||||
disabledCallbacks={[]}
|
||||
|
||||
Reference in New Issue
Block a user