diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py new file mode 100644 index 00000000000..e5734f26e95 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING, Final + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .conduct import ConductGuardrail + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"): + """Initialize the Conduct guardrail from LiteLLM's config block. + + Maps LiteLLM's idiomatic ``api_base`` / ``api_key`` to Conduct's + ``api_url`` / ``agent_token`` constructor arguments. All other + settings pass through unchanged. + """ + import litellm + + _conduct_callback: Final = ConductGuardrail( + api_url=getattr(litellm_params, "api_base", None), + agent_token=getattr(litellm_params, "api_key", None), + workspace_id=getattr(litellm_params, "workspace_id", None), + fail_mode=getattr(litellm_params, "fail_mode", "fail_closed"), + tool_name=getattr(litellm_params, "tool_name", "llm_call"), + timeout=getattr(litellm_params, "timeout", 8.0), + guardrail_name=guardrail.get("guardrail_name", ""), + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + ) + litellm.logging_callback_manager.add_litellm_callback(_conduct_callback) + + return _conduct_callback + + +guardrail_initializer_registry: Final = { + SupportedGuardrailIntegrations.CONDUCT.value: initialize_guardrail, +} + + +guardrail_class_registry: Final = { + SupportedGuardrailIntegrations.CONDUCT.value: ConductGuardrail, +} diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py new file mode 100644 index 00000000000..70d882dbd2b --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py @@ -0,0 +1,26 @@ +"""Conduct Guard as a LiteLLM guardrail. + +Thin re-export. The adapter, response-envelope parser, session-ID chain, +and fail-mode logic all live in the `conduct-litellm-guard` PyPI package, +which is where issues, versioning, and standalone-user support live. + +Install: `pip install conduct-litellm-guard` +Source: https://github.com/sseshachala/conductai/tree/main/packages/conduct-litellm-guard +Docs: https://conductai.ai/guard +""" + +from __future__ import annotations + +try: + from conduct_litellm_guard import ConductGuard as ConductGuardrail + from conduct_litellm_guard.guardrail import ( + ConductGuardBlocked as ConductGuardrailBlocked, + ) + from conduct_litellm_guard.guardrail import GuardDecision +except ImportError as _e: + raise ImportError( + "conduct-litellm-guard is required for the Conduct guardrail. Install with: pip install conduct-litellm-guard" + ) from _e + + +__all__ = ["ConductGuardrail", "ConductGuardrailBlocked", "GuardDecision"] diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 4cf4fa62eff..3275e9c80cf 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -136,6 +136,7 @@ class SupportedGuardrailIntegrations(Enum): HEADROOM = "headroom" COMPRESR = "compresr" STRAIKER = "straiker" + CONDUCT = "conduct" class Role(Enum): diff --git a/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py b/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py new file mode 100644 index 00000000000..bb269cbd5be --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py @@ -0,0 +1,263 @@ +"""Wiring tests for the Conduct guardrail integration. + +The real adapter, response-envelope parser, session-ID chain, and +fail-mode logic live in the ``conduct-litellm-guard`` PyPI package and +are tested there. This file only verifies that the LiteLLM-tree +wrapper wires the enum, registries, and initializer correctly. + +To keep coverage useful on BerriAI's CI (where the standalone package +is not installed by default), we mock ``conduct_litellm_guard`` into +``sys.modules`` before importing the wrapper. This lets every test run +regardless of whether the real PyPI package is present in the test +environment. +""" + +from __future__ import annotations + +import importlib +import sys +import types +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Fake standalone package +# --------------------------------------------------------------------------- +# +# The wrapper in ``litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py`` +# imports three symbols: +# +# from conduct_litellm_guard import ConductGuard +# from conduct_litellm_guard.guardrail import ConductGuardBlocked, GuardDecision +# +# We stand up a fake package tree in ``sys.modules`` that mirrors that shape +# so imports succeed without the real PyPI package being installed. + + +def _install_fake_conduct_package() -> type: + """Register a fake ``conduct_litellm_guard`` package tree in sys.modules. + + Returns the fake ``ConductGuard`` class so tests can assert against it. + """ + from litellm.integrations.custom_guardrail import CustomGuardrail + + class _FakeConductGuard(CustomGuardrail): + def __init__(self, **kwargs: Any) -> None: + self.init_kwargs = kwargs + super().__init__(guardrail_name=kwargs.get("guardrail_name", "")) + + class _FakeConductGuardBlocked(Exception): + pass + + class _FakeGuardDecision: + ALLOW = "allow" + BLOCK = "block" + + root = types.ModuleType("conduct_litellm_guard") + root.ConductGuard = _FakeConductGuard # type: ignore[attr-defined] + + guardrail_mod = types.ModuleType("conduct_litellm_guard.guardrail") + guardrail_mod.ConductGuardBlocked = _FakeConductGuardBlocked # type: ignore[attr-defined] + guardrail_mod.GuardDecision = _FakeGuardDecision # type: ignore[attr-defined] + + sys.modules["conduct_litellm_guard"] = root + sys.modules["conduct_litellm_guard.guardrail"] = guardrail_mod + + return _FakeConductGuard + + +def _forget_wrapper_modules() -> None: + """Drop cached wrapper imports so subsequent imports re-run the module body.""" + for name in list(sys.modules): + if name.startswith("litellm.proxy.guardrails.guardrail_hooks.conduct"): + del sys.modules[name] + + +@pytest.fixture +def fake_conduct(monkeypatch: pytest.MonkeyPatch) -> type: + """Install the fake package and reset wrapper imports for each test.""" + _forget_wrapper_modules() + fake_cls = _install_fake_conduct_package() + yield fake_cls + _forget_wrapper_modules() + sys.modules.pop("conduct_litellm_guard", None) + sys.modules.pop("conduct_litellm_guard.guardrail", None) + + +# --------------------------------------------------------------------------- +# Wiring smoke tests +# --------------------------------------------------------------------------- + + +def test_wrapper_module_imports(fake_conduct: type) -> None: + """The wrapper module imports cleanly against the fake package.""" + module = importlib.import_module("litellm.proxy.guardrails.guardrail_hooks.conduct") + assert module.ConductGuardrail is fake_conduct + + +def test_wrapper_exports_expected_symbols(fake_conduct: type) -> None: + """Public re-exports match the documented ``__all__``.""" + module = importlib.import_module( + "litellm.proxy.guardrails.guardrail_hooks.conduct.conduct" + ) + assert set(module.__all__) == { + "ConductGuardrail", + "ConductGuardrailBlocked", + "GuardDecision", + } + assert module.ConductGuardrail is fake_conduct + assert issubclass(module.ConductGuardrailBlocked, Exception) + + +def test_class_is_custom_guardrail_subclass(fake_conduct: type) -> None: + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy.guardrails.guardrail_hooks.conduct import ConductGuardrail + + assert issubclass(ConductGuardrail, CustomGuardrail) + + +def test_enum_value_registered() -> None: + """Enum entry exists regardless of standalone package availability.""" + from litellm.types.guardrails import SupportedGuardrailIntegrations + + assert SupportedGuardrailIntegrations.CONDUCT.value == "conduct" + + +def test_registries_populated(fake_conduct: type) -> None: + from litellm.proxy.guardrails.guardrail_hooks.conduct import ( + guardrail_class_registry, + guardrail_initializer_registry, + ) + + assert guardrail_class_registry["conduct"] is fake_conduct + assert callable(guardrail_initializer_registry["conduct"]) + + +# --------------------------------------------------------------------------- +# initialize_guardrail — cover happy path and populated-params path +# --------------------------------------------------------------------------- + + +def test_initialize_guardrail_defaults( + fake_conduct: type, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When LiteLLM params are minimal, defaults are passed to ConductGuard.""" + added_callbacks: list[object] = [] + fake_manager = SimpleNamespace( + add_litellm_callback=lambda cb: added_callbacks.append(cb), + ) + import litellm + + monkeypatch.setattr(litellm, "logging_callback_manager", fake_manager) + + from litellm.proxy.guardrails.guardrail_hooks.conduct import ( + initialize_guardrail, + ) + + litellm_params = SimpleNamespace( + api_base=None, + api_key=None, + mode="pre_call", + default_on=True, + ) + guardrail = MagicMock() + guardrail.get.return_value = "conduct-guard" + + callback = initialize_guardrail(litellm_params, guardrail) + + assert isinstance(callback, fake_conduct) + assert added_callbacks == [callback] + kwargs = callback.init_kwargs + assert kwargs["api_url"] is None + assert kwargs["agent_token"] is None + assert kwargs["workspace_id"] is None + assert kwargs["fail_mode"] == "fail_closed" + assert kwargs["tool_name"] == "llm_call" + assert kwargs["timeout"] == 8.0 + assert kwargs["event_hook"] == "pre_call" + assert kwargs["default_on"] is True + + +def test_initialize_guardrail_populated_params( + fake_conduct: type, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-default LiteLLM params flow through to ConductGuard constructor.""" + added_callbacks: list[object] = [] + fake_manager = SimpleNamespace( + add_litellm_callback=lambda cb: added_callbacks.append(cb), + ) + import litellm + + monkeypatch.setattr(litellm, "logging_callback_manager", fake_manager) + + from litellm.proxy.guardrails.guardrail_hooks.conduct import ( + initialize_guardrail, + ) + + litellm_params = SimpleNamespace( + api_base="https://api.conductai.ai/guard", + api_key="cond_agt_test_placeholder", + workspace_id="ws_123", + fail_mode="fail_open", + tool_name="chat_completion", + timeout=15.0, + mode="post_call", + default_on=False, + ) + guardrail = MagicMock() + guardrail.get.return_value = "conduct-prod" + + callback = initialize_guardrail(litellm_params, guardrail) + + assert isinstance(callback, fake_conduct) + kwargs = callback.init_kwargs + assert kwargs["api_url"] == "https://api.conductai.ai/guard" + assert kwargs["agent_token"] == "cond_agt_test_placeholder" + assert kwargs["workspace_id"] == "ws_123" + assert kwargs["fail_mode"] == "fail_open" + assert kwargs["tool_name"] == "chat_completion" + assert kwargs["timeout"] == 15.0 + assert kwargs["guardrail_name"] == "conduct-prod" + assert kwargs["event_hook"] == "post_call" + assert kwargs["default_on"] is False + + +# --------------------------------------------------------------------------- +# Missing-package branch +# --------------------------------------------------------------------------- + + +def test_missing_standalone_package_raises_helpful_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The wrapper points users at ``pip install`` when the standalone + ``conduct-litellm-guard`` package is not installed.""" + _forget_wrapper_modules() + sys.modules.pop("conduct_litellm_guard", None) + sys.modules.pop("conduct_litellm_guard.guardrail", None) + + real_import = ( + __builtins__["__import__"] # type: ignore[index] + if isinstance(__builtins__, dict) + else __builtins__.__import__ + ) + + def _fake_import(name: str, *args: object, **kwargs: object) -> object: + if name.startswith("conduct_litellm_guard"): + raise ImportError("simulated missing package") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _fake_import) + + with pytest.raises(ImportError, match="pip install conduct-litellm-guard"): + importlib.import_module( + "litellm.proxy.guardrails.guardrail_hooks.conduct.conduct" + ) + + _forget_wrapper_modules()