From 97a45a4f65d9bbfae70d05cd73e83b39995b9011 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 11 Sep 2026 21:45:13 +0000 Subject: [PATCH] fix(guardrails): send tool-call-only turns to Conduct and test registry wiring through config load Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_hooks/conduct/conduct.py | 5 +- .../guardrail_hooks/test_conduct.py | 67 ++++++++++--------- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py index 3b5655bbb40..726b23f3d87 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py @@ -39,11 +39,10 @@ def request_payload( request_data: Mapping[str, object], input_type: Literal["request", "response"], ) -> Mapping[str, object] | None: - texts: Final = inputs.get("texts") or () - if input_type != "request" or not texts: + if input_type != "request": return None messages: Final = inputs.get("structured_messages") or tuple( - ChatCompletionUserMessage(role="user", content=text) for text in texts + ChatCompletionUserMessage(role="user", content=text) for text in inputs.get("texts") or () ) return MappingProxyType({**request_data, "prompt": None, "messages": messages}) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py index fcaef1a5030..e1c3c6c25c5 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py @@ -19,11 +19,10 @@ from litellm.proxy.guardrails.guardrail_hooks.conduct import ( initialize_guardrail, ) from litellm.proxy.guardrails.guardrail_hooks.conduct.conduct import apply_conduct_guardrail, request_payload -from litellm.proxy.guardrails.guardrail_registry import ( - guardrail_class_registry, - guardrail_initializer_registry, -) +from litellm.proxy.guardrails.guardrail_endpoints import get_guardrail_ui_settings +from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler from litellm.types.guardrails import Guardrail, GuardrailEventHooks, LitellmParams +from litellm.types.llms.openai import ChatCompletionAssistantMessage from litellm.types.utils import GenericGuardrailAPIInputs PACKAGE_INSTALLED: Final = importlib.util.find_spec("conduct_litellm_guard") is not None @@ -98,11 +97,6 @@ def _isolate_callbacks(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(litellm, "callbacks", []) -def test_discovered_by_guardrail_registry() -> None: - assert guardrail_initializer_registry["conduct"] is initialize_guardrail - assert guardrail_class_registry["conduct"] is ConductGuardrail - - def test_maps_typed_fields_and_extras_onto_plugin_kwargs() -> None: callback: Final = _init( _params( @@ -148,10 +142,8 @@ def test_rejects_modes_the_plugin_does_not_implement(mode: str, monkeypatch: pyt @pytest.mark.skipif(PACKAGE_INSTALLED, reason="exercises the missing-package fallback") def test_missing_package_fails_at_config_load_with_install_hint() -> None: - litellm_params: Final = _params() - with pytest.raises(ImportError, match="pip install"): - initialize_guardrail(litellm_params, _guardrail(litellm_params)) + InMemoryGuardrailHandler().initialize_guardrail(_guardrail(_params())) assert litellm.callbacks == [] @@ -181,16 +173,24 @@ def test_request_payload_keeps_roles_when_translation_provides_them() -> None: assert payload == {"prompt": None, "messages": structured} -@pytest.mark.parametrize( - ("inputs", "input_type"), - [ - (GenericGuardrailAPIInputs(texts=["pong"]), "response"), - (GenericGuardrailAPIInputs(texts=[]), "request"), - (GenericGuardrailAPIInputs(), "request"), - ], -) -def test_request_payload_skips_responses_and_empty_requests(inputs: GenericGuardrailAPIInputs, input_type: str) -> None: - assert request_payload(inputs, {"model": "gpt-5-mini"}, input_type) is None # pyright: ignore[reportArgumentType] # parametrized literal +def test_request_payload_skips_model_responses() -> None: + assert request_payload(GenericGuardrailAPIInputs(texts=["pong"]), {"model": "gpt-5-mini"}, "response") is None + + +@pytest.mark.asyncio +async def test_tool_call_only_turns_still_reach_conduct() -> None: + check: Final = _RecordingCheck("block") + tool_call_turn: Final = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[{"id": "call_1", "type": "function", "function": {"name": "sql", "arguments": "{}"}}], + ) + inputs: Final = GenericGuardrailAPIInputs(texts=[], structured_messages=[tool_call_turn]) + + with pytest.raises(_Blocked): + await apply_conduct_guardrail(inputs, {"model": "gpt-5-mini"}, "request", check, _Blocked) + + assert check.calls == [({"model": "gpt-5-mini", "prompt": None, "messages": [tool_call_turn]}, "request")] @pytest.mark.parametrize("verdict", ["block", "approval"]) @@ -252,14 +252,21 @@ async def test_apply_guardrail_blocks_on_conduct_verdict() -> None: @pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") -def test_plugin_class_enforces_supported_modes() -> None: - assert ConductGuardrail.get_supported_event_hooks() == [GuardrailEventHooks.pre_call] +def test_config_loads_conduct_and_rejects_modes_the_plugin_lacks() -> None: + handler: Final = InMemoryGuardrailHandler() - accepted: Final = _params() - callback: Final = initialize_guardrail(accepted, _guardrail(accepted)) - assert callback.supported_event_hooks == [GuardrailEventHooks.pre_call] - assert litellm.callbacks == [callback] + loaded: Final = handler.initialize_guardrail(_guardrail(_params())) + assert loaded is not None + assert loaded["litellm_params"].guardrail == "conduct" + assert [type(callback) for callback in litellm.callbacks] == [ConductGuardrail] - rejected: Final = _params(mode="during_call") with pytest.raises(ValueError, match="not in the supported event hooks"): - initialize_guardrail(rejected, _guardrail(rejected)) + handler.initialize_guardrail(_guardrail(_params(mode="during_call"))) + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +@pytest.mark.asyncio +async def test_ui_only_offers_pre_call_for_conduct() -> None: + settings: Final = await get_guardrail_ui_settings() + + assert settings.supported_modes_by_provider["conduct"] == ["pre_call"]