From e81c8b7eb23c17fa0847d80f48944cdbe866867d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:23:10 +0000 Subject: [PATCH] feat(guardrails): runtime enable/disable toggle for guardrails via API and UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 3 + .../proxy/guardrails/guardrail_endpoints.py | 81 +++++++++ .../proxy/guardrails/guardrail_registry.py | 31 ++++ litellm/proxy/proxy_server.py | 3 + litellm/types/guardrails.py | 11 ++ .../guardrail_hooks/test_headroom.py | 34 ++++ .../guardrails/test_guardrail_endpoints.py | 166 ++++++++++++++++++ .../guardrails/test_guardrail_registry.py | 106 +++++++++++ .../_components/GuardrailModeCard.tsx | 62 +++++++ .../_components/GuardrailsPanel.test.tsx | 28 ++- .../_components/GuardrailsPanel.tsx | 17 +- .../_components/guardrailTableColumns.tsx | 19 ++ .../_components/guardrail_info.test.tsx | 63 +++++++ .../guardrails/_components/guardrail_info.tsx | 21 ++- .../_components/guardrail_table.test.tsx | 62 ++++++- .../_components/guardrail_table.tsx | 6 +- .../src/components/guardrails/types.ts | 1 + .../src/components/networking.tsx | 21 +++ 18 files changed, 718 insertions(+), 17 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeCard.tsx diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 2d66a280663..3b60bb0b34c 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -222,6 +222,7 @@ class CustomGuardrail(CustomLogger): self.run_in_parallel: bool = run_in_parallel self.scan_raw_request: bool = scan_raw_request self.only_scan_new_messages: bool = only_scan_new_messages + self.enabled: bool = True if supported_event_hooks: ## validate event_hook is in supported_event_hooks @@ -973,6 +974,8 @@ class CustomGuardrail(CustomLogger): """ Returns True if the guardrail should be run on the event_type """ + if not self.enabled: + return False if self._suppressed_by_auto_router_compression(): return False diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index afb9997f2e6..c20f85874e6 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -48,6 +48,8 @@ from litellm.types.guardrails import ( PiiAction, PiiEntityType, PresidioPresidioConfigModelUserInterface, + SetGuardrailEnabledRequest, + SetGuardrailEnabledResponse, SupportedGuardrailIntegrations, ToolPermissionGuardrailConfigModel, ) @@ -269,6 +271,7 @@ async def list_guardrails_v2( created_at=guardrail.get("created_at"), updated_at=guardrail.get("updated_at"), guardrail_definition_location="db", + enabled=IN_MEMORY_GUARDRAIL_HANDLER.is_enabled(guardrail.get("guardrail_id") or ""), ) ) seen_guardrail_ids.add(guardrail.get("guardrail_id")) @@ -308,6 +311,7 @@ async def list_guardrails_v2( litellm_params=masked_in_memory_litellm_params_typed, guardrail_info=dict(guardrail.get("guardrail_info") or {}), guardrail_definition_location="config", + enabled=IN_MEMORY_GUARDRAIL_HANDLER.is_enabled(gid), ) ) seen_guardrail_ids.add(gid) @@ -1281,6 +1285,82 @@ async def patch_guardrail( raise HTTPException(status_code=500, detail=str(e)) +@router.patch( + "/guardrails/{guardrail_id}/enabled", + tags=["Guardrails"], + response_model=SetGuardrailEnabledResponse, +) +async def set_guardrail_enabled( + guardrail_id: str, + request: SetGuardrailEnabledRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Turn a guardrail off or back on at runtime without editing or deleting it + + Works for guardrails defined in config.yaml as well as ones created in the DB. + A disabled guardrail stays registered but skips every request until it is + re-enabled. The state is persisted, so it survives restarts and applies to + all proxy instances sharing the DB. + + Example Request: + ```bash + curl -X PATCH "http://localhost:4000/guardrails/123e4567-e89b-12d3-a456-426614174000/enabled" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"enabled": false}' + ``` + + Example Response: + ```json + { + "guardrail_id": "123e4567-e89b-12d3-a456-426614174000", + "guardrail_name": "headroom-compression", + "enabled": false + } + ``` + """ + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Admin access required to manage guardrails", + ) + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + db_guardrail: Final = await GUARDRAIL_REGISTRY.get_guardrail_by_id_from_db( + guardrail_id=guardrail_id, prisma_client=prisma_client + ) + config_guardrail: Final = ( + IN_MEMORY_GUARDRAIL_HANDLER.get_guardrail_by_id(guardrail_id=guardrail_id) + if IN_MEMORY_GUARDRAIL_HANDLER.get_source(guardrail_id) == "config" + else None + ) + guardrail: Final = db_guardrail if db_guardrail is not None else config_guardrail + if guardrail is None: + raise HTTPException(status_code=404, detail=f"Guardrail with ID {guardrail_id} not found") + + disabled_guardrail_ids: Final = await GUARDRAIL_REGISTRY.set_guardrail_enabled_in_db( + guardrail_id=guardrail_id, enabled=request.enabled, prisma_client=prisma_client + ) + IN_MEMORY_GUARDRAIL_HANDLER.set_disabled_guardrails(disabled_guardrail_ids) + verbose_proxy_logger.info( + "Guardrail '%s' (ID: %s) %s", + guardrail.get("guardrail_name"), + guardrail_id, + "enabled" if request.enabled else "disabled", + ) + return SetGuardrailEnabledResponse( + guardrail_id=guardrail_id, + guardrail_name=guardrail.get("guardrail_name") or "", + enabled=request.enabled, + ) + + @router.get( "/guardrails/{guardrail_id}", tags=["Guardrails"], @@ -1369,6 +1449,7 @@ async def get_guardrail_info(guardrail_id: str): created_at=result.get("created_at"), updated_at=result.get("updated_at"), guardrail_definition_location=guardrail_definition_location, + enabled=IN_MEMORY_GUARDRAIL_HANDLER.is_enabled(guardrail_id), ) except HTTPException as e: raise e diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 0dc50cd6196..adc3f4692ed 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -39,6 +39,7 @@ from litellm.proxy.guardrails.guardrail_hooks.tool_permission import ( ) from litellm.proxy.types_utils.utils import get_instance_fn from litellm.proxy.utils import PrismaClient +from litellm.repositories.config_repository import ConfigRepository from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.table_repositories import GuardrailsRepository from litellm.secret_managers.main import get_secret @@ -90,6 +91,8 @@ guardrail_initializer_registry: Final = { CONFIG_GUARDRAIL_ID_NAMESPACE: Final = uuid.UUID("625f63f4-935a-50e5-98b5-fbe77babc74a") +DISABLED_GUARDRAILS_CONFIG_PARAM: Final = "disabled_guardrails" + GuardrailCallbacks: TypeAlias = tuple[CustomGuardrail, ...] guardrail_class_registry: Final[dict[str, type[CustomGuardrail]]] = { @@ -414,6 +417,22 @@ class GuardrailRegistry: except Exception as e: raise Exception(f"Error getting guardrail from DB: {e}") + @staticmethod + async def get_disabled_guardrail_ids_from_db(prisma_client: PrismaClient) -> frozenset[str]: + param: Final = await ConfigRepository(prisma_client).get_param(DISABLED_GUARDRAILS_CONFIG_PARAM) + if param is None or not isinstance(param.param_value, list): + return frozenset() + return frozenset(item for item in param.param_value if isinstance(item, str)) + + @staticmethod + async def set_guardrail_enabled_in_db( + guardrail_id: str, enabled: bool, prisma_client: PrismaClient + ) -> frozenset[str]: + current: Final = await GuardrailRegistry.get_disabled_guardrail_ids_from_db(prisma_client) + updated: Final = current - {guardrail_id} if enabled else current | {guardrail_id} + await ConfigRepository(prisma_client).set_param(DISABLED_GUARDRAILS_CONFIG_PARAM, sorted(updated)) + return updated + def _apply_configured_bool_overrides(instance: CustomGuardrail, litellm_params: LitellmParams) -> None: """Override the parallel/raw-scan flags only when ``litellm_params`` explicitly @@ -486,6 +505,8 @@ class InMemoryGuardrailHandler: and never deleted by reconciliation. """ + self._disabled_guardrail_ids: frozenset[str] = frozenset() + def _stable_guardrail_id(self, guardrail_name: str) -> str: seeds: Final = chain((guardrail_name,), (f"{guardrail_name}:{occurrence}" for occurrence in count(1))) candidate_ids: Final = (str(uuid.uuid5(CONFIG_GUARDRAIL_ID_NAMESPACE, seed.encode("utf-8"))) for seed in seeds) @@ -545,6 +566,7 @@ class InMemoryGuardrailHandler: ) for custom_guardrail_callback in created_callbacks: _configure_callback_scoping(custom_guardrail_callback, guardrail["guardrail_name"], litellm_params) + custom_guardrail_callback.enabled = guardrail_id not in self._disabled_guardrail_ids parsed_guardrail: Final = Guardrail( guardrail_id=guardrail.get("guardrail_id"), @@ -702,6 +724,15 @@ class InMemoryGuardrailHandler: """ return self._sources.get(guardrail_id) + def is_enabled(self, guardrail_id: str) -> bool: + return guardrail_id not in self._disabled_guardrail_ids + + def set_disabled_guardrails(self, disabled_guardrail_ids: frozenset[str]) -> None: + self._disabled_guardrail_ids = disabled_guardrail_ids + for guardrail_id in self.IN_MEMORY_GUARDRAILS: + for callback in self._tracked_callbacks(guardrail_id): + callback.enabled = guardrail_id not in disabled_guardrail_ids + def list_config_guardrails(self) -> list[Guardrail]: """ List in-memory guardrails owned by config.yaml. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 32b6b841af7..0ff599ab8f9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7727,6 +7727,9 @@ class ProxyConfig: # Drop in-memory DB-backed entries whose row was deleted on another # pod. Config-loaded entries are never touched. IN_MEMORY_GUARDRAIL_HANDLER.reconcile_db_guardrails(db_guardrail_ids=db_guardrail_ids) + IN_MEMORY_GUARDRAIL_HANDLER.set_disabled_guardrails( + await GuardrailRegistry.get_disabled_guardrail_ids_from_db(prisma_client=prisma_client) + ) except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.py::ProxyConfig:_init_guardrails_in_db - %s", e) diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 02dee40f2a3..8ebec7b335b 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -1173,6 +1173,7 @@ class GuardrailInfoResponse(BaseModel): created_at: datetime | None = None updated_at: datetime | None = None guardrail_definition_location: GUARDRAIL_DEFINITION_LOCATION = GUARDRAIL_DEFINITION_LOCATION.CONFIG + enabled: bool = True def __init__(self, **kwargs) -> None: super().__init__(**kwargs) @@ -1218,3 +1219,13 @@ class PatchGuardrailRequest(BaseModel): guardrail_name: str | None = None litellm_params: BaseLitellmParams | None = None guardrail_info: dict[str, Any] | None = None + + +class SetGuardrailEnabledRequest(BaseModel): + enabled: bool + + +class SetGuardrailEnabledResponse(BaseModel): + guardrail_id: str + guardrail_name: str + enabled: bool diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py index d8eeb8d2b8a..e7b6ff0cbb1 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -2966,3 +2966,37 @@ def test_unusable_timeout_falls_back_to_the_default(configured: float): assert guardrail.timeout.read == 60.0 assert guardrail.timeout.connect == 5.0 + + +@pytest.mark.asyncio +async def test_disabled_headroom_guardrail_skips_compress_and_forwards_request_unchanged(): + """ + A Headroom guardrail switched off at runtime (PATCH /guardrails/{id}/enabled) + must not call /v1/compress at all, even though it is default_on and the + request names it explicitly; the proxy pipeline forwards the request as is. + """ + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import ProxyLogging + from litellm.types.guardrails import GuardrailEventHooks + + guardrail = _make_guardrail(guardrail_name="headroom-compression-global") + data = {"model": "gpt-4o", "messages": ORIGINAL_MESSAGES, "metadata": {"guardrails": ["headroom-compression-global"]}} + proxy_logging = ProxyLogging(user_api_key_cache=UserApiKeyCache()) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as post: + assert guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) is True + + guardrail.enabled = False + skipped = await proxy_logging._process_guardrail_callback( + callback=guardrail, + data=data, + user_api_key_dict=UserAPIKeyAuth(), + call_type="acompletion", + event_type=GuardrailEventHooks.pre_call, + ) + + assert skipped is None + post.assert_not_called() + assert data["messages"] == ORIGINAL_MESSAGES + assert _recorded_guardrail_entries(data) == [] diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 530f8ffd854..b843c3de2dd 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -100,6 +100,7 @@ def mock_prisma_client(mocker): def mock_in_memory_handler(mocker): """Mock InMemoryGuardrailHandler for testing""" mock_handler = mocker.Mock(spec=InMemoryGuardrailHandler) + mock_handler.is_enabled.return_value = True mock_handler.list_in_memory_guardrails.return_value = [MOCK_CONFIG_GUARDRAIL] mock_handler.get_guardrail_by_id.return_value = MOCK_CONFIG_GUARDRAIL mock_handler.get_source.return_value = "config" @@ -179,6 +180,7 @@ async def test_list_guardrails_v2_skips_stale_db_backed_in_memory_entries(mocker mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[]) mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.list_in_memory_guardrails.return_value = [stale_guardrail] mock_in_memory_handler.get_source.return_value = "db" @@ -246,6 +248,7 @@ async def test_list_guardrails_v2_masks_sensitive_data_in_db_guardrails(mocker): ) mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.list_in_memory_guardrails.return_value = [] mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -299,6 +302,7 @@ async def test_list_guardrails_v2_masks_sensitive_data_in_config_guardrails(mock mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[]) mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.list_in_memory_guardrails.return_value = [ config_guardrail_with_secrets ] @@ -360,6 +364,7 @@ async def test_list_guardrails_v2_admin_viewer_sees_guardrails_of_teams_they_are ) mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.list_in_memory_guardrails.return_value = [] mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -409,6 +414,7 @@ async def test_list_guardrails_v2_masks_sensitive_data_for_admin_viewer(mocker): ) mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.list_in_memory_guardrails.return_value = [] mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -1762,6 +1768,7 @@ async def test_get_guardrail_info_endpoint_config_guardrail(mocker): # Mock IN_MEMORY_GUARDRAIL_HANDLER at its source to return config guardrail mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.get_guardrail_by_id.return_value = MOCK_CONFIG_GUARDRAIL mock_in_memory_handler.get_source.return_value = "config" mocker.patch( @@ -1807,6 +1814,7 @@ async def test_get_guardrail_info_endpoint_db_guardrail(mocker): # Mock IN_MEMORY_GUARDRAIL_HANDLER to return None mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.is_enabled.return_value = True mock_in_memory_handler.get_guardrail_by_id.return_value = None mocker.patch( "litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", @@ -2709,3 +2717,161 @@ def test_field_type_inference_handles_pep604_unions(): assert _get_field_type_from_annotation(list[str] | None) == "array" assert _get_field_type_from_annotation(bool | None) == "boolean" assert _unwrap_optional_type(str | None) is str + + +def _prisma_with_config_store(mocker, stored: dict, db_guardrails: list): + async def find_unique(*, where): + value = stored.get(where["param_name"]) + return None if value is None else mocker.Mock(param_name=where["param_name"], param_value=value) + + async def upsert(*, where, data): + stored[where["param_name"]] = data["update"]["param_value"] + return mocker.Mock() + + prisma_client = mocker.Mock() + prisma_client.db.litellm_config.find_unique = find_unique + prisma_client.db.litellm_config.upsert = upsert + prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(return_value=db_guardrails) + prisma_client.db.litellm_guardrailstable.find_unique = AsyncMock( + side_effect=lambda where: next( + (g for g in db_guardrails if g["guardrail_id"] == where["guardrail_id"]), None + ) + ) + return prisma_client + + +def _real_handler_with_config_guardrail(mocker, name: str): + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy.guardrails import guardrail_registry as registry_module + from litellm.types.guardrails import GuardrailEventHooks + + registry_module.guardrail_initializer_registry["toggle_endpoint_test"] = lambda litellm_params, guardrail: ( + CustomGuardrail( + guardrail_name=guardrail["guardrail_name"], event_hook=GuardrailEventHooks.pre_call, default_on=True + ) + ) + handler = InMemoryGuardrailHandler() + result = handler.initialize_guardrail( + guardrail={ + "guardrail_name": name, + "litellm_params": {"guardrail": "toggle_endpoint_test", "mode": "pre_call"}, + } + ) + mocker.patch("litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", handler) + return handler, result["guardrail_id"] + + +@pytest.mark.asyncio +async def test_set_guardrail_enabled_requires_admin(mocker): + from litellm.proxy.guardrails.guardrail_endpoints import set_guardrail_enabled + from litellm.types.guardrails import SetGuardrailEnabledRequest + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock()) + + with pytest.raises(HTTPException) as exc_info: + await set_guardrail_enabled( + guardrail_id="any", + request=SetGuardrailEnabledRequest(enabled=False), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER), + ) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_set_guardrail_enabled_toggles_config_guardrail_and_surfaces_in_list_and_info(mocker): + """ + End to end through the endpoints: a config.yaml guardrail (no DB row) is + disabled, the disabled id is persisted to LiteLLM_Config, the live callback + stops running, /v2/guardrails/list and /guardrails/{id}/info report + enabled=false, and re-enabling reverses all of it. + """ + from litellm.proxy.guardrails import guardrail_registry as registry_module + from litellm.proxy.guardrails.guardrail_endpoints import set_guardrail_enabled + from litellm.types.guardrails import GuardrailEventHooks, SetGuardrailEnabledRequest + + stored: dict = {} + prisma_client = _prisma_with_config_store(mocker, stored, db_guardrails=[]) + mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) + try: + handler, gid = _real_handler_with_config_guardrail(mocker, "headroom-compression") + instance = handler.guardrail_id_to_custom_guardrail[gid] + + disabled = await set_guardrail_enabled( + guardrail_id=gid, request=SetGuardrailEnabledRequest(enabled=False), user_api_key_dict=MOCK_ADMIN_USER + ) + + assert disabled.model_dump() == {"guardrail_id": gid, "guardrail_name": "headroom-compression", "enabled": False} + assert stored["disabled_guardrails"] == json.dumps([gid]) + assert instance.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is False + listed = await list_guardrails_v2(user_api_key_dict=MOCK_ADMIN_USER) + assert [(g.guardrail_id, g.enabled, g.guardrail_definition_location) for g in listed.guardrails] == [ + (gid, False, "config") + ] + assert (await get_guardrail_info(gid)).enabled is False + + enabled = await set_guardrail_enabled( + guardrail_id=gid, request=SetGuardrailEnabledRequest(enabled=True), user_api_key_dict=MOCK_ADMIN_USER + ) + + assert enabled.enabled is True + assert stored["disabled_guardrails"] == "[]" + assert instance.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is True + assert (await list_guardrails_v2(user_api_key_dict=MOCK_ADMIN_USER)).guardrails[0].enabled is True + assert (await get_guardrail_info(gid)).enabled is True + finally: + registry_module.guardrail_initializer_registry.pop("toggle_endpoint_test", None) + + +@pytest.mark.asyncio +async def test_set_guardrail_enabled_disables_db_guardrail_without_touching_its_row(mocker): + from litellm.proxy.guardrails.guardrail_endpoints import set_guardrail_enabled + from litellm.types.guardrails import SetGuardrailEnabledRequest + + stored: dict = {} + prisma_client = _prisma_with_config_store(mocker, stored, db_guardrails=[MOCK_DB_GUARDRAIL]) + prisma_client.db.litellm_guardrailstable.update = AsyncMock() + prisma_client.db.litellm_guardrailstable.delete = AsyncMock() + mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) + handler = InMemoryGuardrailHandler() + mocker.patch("litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", handler) + + result = await set_guardrail_enabled( + guardrail_id="test-db-guardrail", + request=SetGuardrailEnabledRequest(enabled=False), + user_api_key_dict=MOCK_ADMIN_USER, + ) + + assert result.enabled is False + assert result.guardrail_name == "Test DB Guardrail" + assert stored["disabled_guardrails"] == '["test-db-guardrail"]' + assert handler.is_enabled("test-db-guardrail") is False + prisma_client.db.litellm_guardrailstable.update.assert_not_called() + prisma_client.db.litellm_guardrailstable.delete.assert_not_called() + listed = await list_guardrails_v2(user_api_key_dict=MOCK_ADMIN_USER) + assert [(g.guardrail_id, g.enabled) for g in listed.guardrails] == [("test-db-guardrail", False)] + + +@pytest.mark.asyncio +async def test_set_guardrail_enabled_404s_unknown_and_stale_db_backed_ids(mocker): + from litellm.proxy.guardrails.guardrail_endpoints import set_guardrail_enabled + from litellm.types.guardrails import SetGuardrailEnabledRequest + + stored: dict = {} + prisma_client = _prisma_with_config_store(mocker, stored, db_guardrails=[]) + mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) + handler = mocker.Mock(spec=InMemoryGuardrailHandler) + handler.get_guardrail_by_id.return_value = MOCK_CONFIG_GUARDRAIL + handler.get_source.return_value = "db" + mocker.patch("litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", handler) + + with pytest.raises(HTTPException) as exc_info: + await set_guardrail_enabled( + guardrail_id="stale-db-id", + request=SetGuardrailEnabledRequest(enabled=False), + user_api_key_dict=MOCK_ADMIN_USER, + ) + + assert exc_info.value.status_code == 404 + assert stored == {} + handler.set_disabled_guardrails.assert_not_called() diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py index 836668de0c8..e2f60bbe8ab 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py @@ -1084,3 +1084,109 @@ def test_sync_guardrail_from_db_applies_db_dict_params_to_live_instance(): finally: for cb_list, snapshot in zip(lists, snapshots): cb_list[:] = snapshot + + +def test_set_disabled_guardrails_gates_should_run_and_re_enables(): + """ + A disabled guardrail must stay registered and listed but never run: the + runtime gate returns False even for a default_on guardrail, and flipping it + back on takes effect immediately without re-initializing anything. + """ + from litellm.proxy.guardrails import guardrail_registry as registry_module + + def _initializer(litellm_params, guardrail): + return CustomGuardrail( + guardrail_name=guardrail["guardrail_name"], + event_hook=GuardrailEventHooks.pre_call, + default_on=True, + ) + + registry_module.guardrail_initializer_registry["toggle_test"] = _initializer + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + handler = InMemoryGuardrailHandler() + result = handler.initialize_guardrail(guardrail=_config_guardrail("headroom-compression", "toggle_test")) + gid = result["guardrail_id"] + instance = handler.guardrail_id_to_custom_guardrail[gid] + + assert handler.is_enabled(gid) is True + assert instance.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is True + + handler.set_disabled_guardrails(frozenset({gid})) + assert handler.is_enabled(gid) is False + assert instance.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is False + assert handler.get_guardrail_by_id(gid) is not None + assert instance in handler._tracked_callbacks(gid) + + handler.set_disabled_guardrails(frozenset()) + assert handler.is_enabled(gid) is True + assert instance.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is True + finally: + registry_module.guardrail_initializer_registry.pop("toggle_test", None) + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot + + +def test_guardrail_initialized_after_disable_starts_disabled(): + """ + Persisted disabled ids are loaded before DB guardrails are (re)initialized on + a pod, so a callback created later for a disabled id must come up disabled + rather than silently re-enabling itself on the next sync. + """ + registry_module = _register_noop_initializer("late_init_test") + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + handler = InMemoryGuardrailHandler() + handler.set_disabled_guardrails(frozenset({"db-guardrail-1"})) + handler.initialize_guardrail( + guardrail=_config_guardrail("late", "late_init_test", guardrail_id="db-guardrail-1") + ) + + instance = handler.guardrail_id_to_custom_guardrail["db-guardrail-1"] + assert instance.enabled is False + assert instance.should_run_guardrail( + data={"metadata": {"guardrails": ["late"]}}, event_type=GuardrailEventHooks.pre_call + ) is False + finally: + registry_module.guardrail_initializer_registry.pop("late_init_test", None) + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot + + +@pytest.mark.asyncio +async def test_set_guardrail_enabled_in_db_round_trips_through_config_table(): + """ + The disabled set lives in LiteLLM_Config so config.yaml guardrails, which have + no row in the guardrails table, can be switched off too. Disable adds the id, + enable removes it, and the stored JSON reads back as the same set. + """ + stored: dict = {} + + async def find_unique(*, where): + value = stored.get(where["param_name"]) + if value is None: + return None + return MagicMock(param_name=where["param_name"], param_value=value) + + async def upsert(*, where, data): + stored[where["param_name"]] = data["update"]["param_value"] + return MagicMock() + + prisma_client = MagicMock() + prisma_client.db.litellm_config.find_unique = find_unique + prisma_client.db.litellm_config.upsert = upsert + + assert await GuardrailRegistry.get_disabled_guardrail_ids_from_db(prisma_client) == frozenset() + + assert await GuardrailRegistry.set_guardrail_enabled_in_db("b", enabled=False, prisma_client=prisma_client) == {"b"} + assert await GuardrailRegistry.set_guardrail_enabled_in_db("a", enabled=False, prisma_client=prisma_client) == { + "a", + "b", + } + assert stored["disabled_guardrails"] == '["a", "b"]' + assert await GuardrailRegistry.get_disabled_guardrail_ids_from_db(prisma_client) == frozenset({"a", "b"}) + + assert await GuardrailRegistry.set_guardrail_enabled_in_db("b", enabled=True, prisma_client=prisma_client) == {"a"} + assert await GuardrailRegistry.get_disabled_guardrail_ids_from_db(prisma_client) == frozenset({"a"}) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeCard.tsx new file mode 100644 index 00000000000..8854e487c51 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeCard.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import { setGuardrailEnabledCall } from "@/components/networking"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/lib/toast"; + +import { formatGuardrailMode } from "./guardrail_info_helpers"; + +interface GuardrailModeCardProps { + guardrailId: string; + mode: unknown; + defaultOn: boolean; + enabled: boolean; + accessToken: string | null; + isAdmin: boolean; + onChanged: () => Promise; +} + +const GuardrailModeCard: React.FC = ({ + guardrailId, + mode, + defaultOn, + enabled, + accessToken, + isAdmin, + onChanged, +}) => { + const handleToggle = async (nextEnabled: boolean) => { + if (!accessToken) return; + try { + await setGuardrailEnabledCall(accessToken, guardrailId, nextEnabled); + toast.success(`Guardrail ${nextEnabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Error updating guardrail enabled state:", error); + toast.fromError(`Failed to ${nextEnabled ? "enable" : "disable"} guardrail`); + } + await onChanged(); + }; + + return ( + +

Mode

+
+

{formatGuardrailMode(mode) || "-"}

+
+ {defaultOn ? "Default On" : "Default Off"} + {enabled ? "Enabled" : "Disabled"} +
+
+ {isAdmin && ( +
+ + {enabled ? "Guardrail runs on requests" : "Guardrail is skipped for every request"} +
+ )} +
+ ); +}; + +export default GuardrailModeCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 7be120a4cb7..2917ba9c948 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -1,12 +1,13 @@ 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 { getGuardrailsList, deleteGuardrailCall, setGuardrailEnabledCall } from "@/components/networking"; import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils"; vi.mock("@/components/networking", () => ({ getGuardrailsList: vi.fn(), deleteGuardrailCall: vi.fn(), + setGuardrailEnabledCall: vi.fn(), })); vi.mock("./add_guardrail_form", () => ({ @@ -16,7 +17,7 @@ vi.mock("./add_guardrail_form", () => ({ vi.mock("./guardrail_table", () => ({ __esModule: true, - default: ({ guardrailsList, onDeleteClick, onGuardrailClick }: any) => ( + default: ({ guardrailsList, onDeleteClick, onGuardrailClick, onToggleEnabled }: any) => (
Mock Guardrail Table
{guardrailsList.length > 0 && ( @@ -30,6 +31,12 @@ vi.mock("./guardrail_table", () => ({ + )}
@@ -100,6 +107,7 @@ describe("GuardrailsPanel", () => { const mockGetGuardrailsList = vi.mocked(getGuardrailsList); const mockDeleteGuardrailCall = vi.mocked(deleteGuardrailCall); + const mockSetGuardrailEnabledCall = vi.mocked(setGuardrailEnabledCall); beforeEach(() => { vi.clearAllMocks(); @@ -117,11 +125,27 @@ describe("GuardrailsPanel", () => { created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", guardrail_definition_location: "database" as any, + enabled: true, }, ], }); }); + it("should disable a guardrail through the toggle and refetch the list", async () => { + mockSetGuardrailEnabledCall.mockResolvedValue({ guardrail_id: "test-guardrail-1", enabled: false }); + renderWithProviders(); + fireEvent.click(screen.getByText("Guardrails")); + + fireEvent.click(await screen.findByTestId("toggle-button")); + + await waitFor(() => { + expect(mockSetGuardrailEnabledCall).toHaveBeenCalledWith("test-token", "test-guardrail-1", false); + }); + await waitFor(() => { + expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2); + }); + }); + it("should render the component", async () => { renderWithProviders(); expect(screen.getByText("Guardrails")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx index 901e39004f3..951fee98342 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx @@ -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, deleteGuardrailCall, setGuardrailEnabledCall } from "@/components/networking"; import { buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -126,6 +126,20 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole setGuardrailToDelete(null); }; + const handleToggleEnabled = async (guardrailId: string, enabled: boolean) => { + if (!accessToken) return; + const guardrail = guardrailsList.find((g) => g.guardrail_id === guardrailId); + const label = guardrail?.guardrail_name || guardrailId; + try { + await setGuardrailEnabledCall(accessToken, guardrailId, enabled); + toast.success(`Guardrail "${label}" ${enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Error updating guardrail enabled state:", error); + toast.fromError(`Failed to ${enabled ? "enable" : "disable"} guardrail`); + } + await fetchGuardrails(); + }; + const providerDisplayName = guardrailToDelete && guardrailToDelete.litellm_params ? getGuardrailLogoAndName(guardrailToDelete.litellm_params.guardrail).displayName @@ -193,6 +207,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole isLoading={isLoading} onDeleteClick={handleDeleteClick} onGuardrailClick={(id) => void setSelectedGuardrailId(id)} + onToggleEnabled={handleToggleEnabled} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx index 53f1b1a03d3..7a0691056c5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx @@ -7,6 +7,7 @@ import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types"; import { buttonVariants } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { DropdownMenu, DropdownMenuContent, @@ -66,11 +67,13 @@ function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsPr interface GuardrailTableColumnsDeps { onGuardrailClick: (guardrailId: string) => void; onDeleteClick: (guardrailId: string, guardrailName: string) => void; + onToggleEnabled: (guardrailId: string, enabled: boolean) => void; } export const getGuardrailTableColumns = ({ onGuardrailClick, onDeleteClick, + onToggleEnabled, }: GuardrailTableColumnsDeps): ColumnDef[] => [ { id: "guardrail_id", @@ -139,6 +142,22 @@ export const getGuardrailTableColumns = ({ ); }, }, + { + id: "enabled", + accessorKey: "enabled", + meta: { title: "Enabled" }, + header: "Enabled", + size: 100, + enableSorting: false, + cell: ({ row }) => ( + onToggleEnabled(row.original.guardrail_id, enabled)} + /> + ), + }, { id: "created_at", accessorKey: "created_at", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx index 836921104ff..d6defb52843 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx @@ -10,6 +10,7 @@ vi.mock("@/components/networking", () => ({ getGuardrailUISettings: vi.fn(), getGuardrailProviderSpecificParams: vi.fn(), updateGuardrailCall: vi.fn(), + setGuardrailEnabledCall: vi.fn(), })); // Mock ContentFilterManager @@ -41,6 +42,68 @@ describe("Guardrail Info", () => { vi.clearAllMocks(); }); + it("lets an admin re-enable a disabled config guardrail from the overview and refetches it", async () => { + vi.mocked(networking.getGuardrailInfo) + .mockResolvedValueOnce({ + guardrail_id: "cfg-1", + guardrail_name: "Headroom", + litellm_params: { guardrail: "headroom", mode: "pre_call", default_on: true }, + guardrail_definition_location: "config", + enabled: false, + }) + .mockResolvedValueOnce({ + guardrail_id: "cfg-1", + guardrail_name: "Headroom", + litellm_params: { guardrail: "headroom", mode: "pre_call", default_on: true }, + guardrail_definition_location: "config", + enabled: true, + }); + vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({ + supported_entities: [], + supported_actions: [], + pii_entity_categories: [], + supported_modes: ["pre_call"], + }); + vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({}); + vi.mocked(networking.setGuardrailEnabledCall).mockResolvedValue({ guardrail_id: "cfg-1", enabled: true }); + + render( {}} accessToken="tok" isAdmin={true} />); + + const toggle = await screen.findByRole("switch", { name: "Enable guardrail" }); + expect(toggle).not.toBeChecked(); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(networking.setGuardrailEnabledCall).toHaveBeenCalledWith("tok", "cfg-1", true); + }); + expect(await screen.findByText("Enabled", { selector: "[data-slot='badge']" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Enable guardrail" })).toBeChecked(); + }); + + it("hides the enable switch from non-admins but still shows the state", async () => { + vi.mocked(networking.getGuardrailInfo).mockResolvedValue({ + guardrail_id: "123", + guardrail_name: "Test Guardrail", + litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true }, + guardrail_definition_location: "db", + enabled: false, + }); + vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({ + supported_entities: [], + supported_actions: [], + pii_entity_categories: [], + supported_modes: ["pre_call"], + }); + vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({}); + + render( {}} accessToken="tok" isAdmin={false} />); + + expect(await screen.findByText("Disabled")).toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: "Enable guardrail" })).not.toBeInTheDocument(); + }); + it("should render the guardrail info after loading", async () => { // Mock the network responses vi.mocked(networking.getGuardrailInfo).mockResolvedValue({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index c9162d99934..29fbcf0adcf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -32,6 +32,7 @@ import { } from "./GuardrailFormField"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager"; import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal"; +import GuardrailModeCard from "./GuardrailModeCard"; import { formatGuardrailMode, getGuardrailLogoAndName, @@ -556,17 +557,15 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, - -

Mode

-
-

- {formatGuardrailMode(guardrailData.litellm_params?.mode) || "-"} -

- - {guardrailData.litellm_params?.default_on ? "Default On" : "Default Off"} - -
-
+

Created At

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx index 561a89a191a..d14daeb6666 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx @@ -9,6 +9,7 @@ const baseProps = { isLoading: false, onDeleteClick: vi.fn(), onGuardrailClick: vi.fn(), + onToggleEnabled: vi.fn(), }; const makeGuardrail = (overrides: Partial = {}): Guardrail => ({ @@ -19,13 +20,23 @@ const makeGuardrail = (overrides: Partial = {}): Guardrail => ({ created_at: "2021-01-01", updated_at: "2021-01-02", guardrail_definition_location: GuardrailDefinitionLocation.DB, + enabled: true, ...overrides, }); describe("GuardrailTable", () => { it("renders every column header", () => { render(); - for (const header of ["Guardrail ID", "Name", "Provider", "Mode", "Default On", "Created At", "Updated At"]) { + for (const header of [ + "Guardrail ID", + "Name", + "Provider", + "Mode", + "Default On", + "Enabled", + "Created At", + "Updated At", + ]) { expect(screen.getByText(header)).toBeInTheDocument(); } }); @@ -86,4 +97,53 @@ describe("GuardrailTable", () => { expect(deleteItem).toHaveAttribute("data-disabled"); expect(onDeleteClick).not.toHaveBeenCalled(); }); + + it("reflects the enabled state in the row switch", () => { + render( + , + ); + + expect(screen.getByRole("switch", { name: "Enable On" })).toBeChecked(); + expect(screen.getByRole("switch", { name: "Enable Off" })).not.toBeChecked(); + }); + + it("toggles a DB guardrail off from the row switch", async () => { + const user = userEvent.setup(); + const onToggleEnabled = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("switch", { name: "Enable Headroom" })); + + expect(onToggleEnabled).toHaveBeenCalledWith("gr-2", false); + }); + + it("lets config guardrails be toggled even though they cannot be deleted", async () => { + const user = userEvent.setup(); + const onToggleEnabled = vi.fn(); + const guardrail = makeGuardrail({ + guardrail_id: "cfg-2", + guardrail_name: "Config Headroom", + guardrail_definition_location: GuardrailDefinitionLocation.CONFIG, + enabled: false, + }); + render(); + + const toggle = screen.getByRole("switch", { name: "Enable Config Headroom" }); + expect(toggle).toBeEnabled(); + await user.click(toggle); + + expect(onToggleEnabled).toHaveBeenCalledWith("cfg-2", true); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx index bbab01e346d..7b977923e6e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx @@ -14,6 +14,7 @@ interface GuardrailTableProps { isLoading: boolean; onDeleteClick: (guardrailId: string, guardrailName: string) => void; onGuardrailClick: (id: string) => void; + onToggleEnabled: (guardrailId: string, enabled: boolean) => void; } const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; @@ -35,12 +36,13 @@ const GuardrailTable: React.FC = ({ isLoading, onDeleteClick, onGuardrailClick, + onToggleEnabled, }) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( - () => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick }), - [onGuardrailClick, onDeleteClick], + () => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick, onToggleEnabled }), + [onGuardrailClick, onDeleteClick, onToggleEnabled], ); return ( diff --git a/ui/litellm-dashboard/src/components/guardrails/types.ts b/ui/litellm-dashboard/src/components/guardrails/types.ts index 0f5ce1c883d..74846bbe334 100644 --- a/ui/litellm-dashboard/src/components/guardrails/types.ts +++ b/ui/litellm-dashboard/src/components/guardrails/types.ts @@ -37,6 +37,7 @@ export interface Guardrail { created_at?: string; updated_at?: string; guardrail_definition_location: GuardrailDefinitionLocation; + enabled: boolean; } export enum GuardrailDefinitionLocation { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0e9133d6d3c..f7c07b98c9b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -6210,6 +6210,27 @@ export const getAgentInfo = async (accessToken: string, agentId: string) => { } }; +export const setGuardrailEnabledCall = async (accessToken: string, guardrailId: string, enabled: boolean) => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}/enabled` : `/guardrails/${guardrailId}/enabled`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + return response.json(); +}; + export const getGuardrailInfo = async (accessToken: string, guardrailId: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}/info` : `/guardrails/${guardrailId}/info`;