From 1b8b17cc8a911e31be0522d8ab57e647e80069bc Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 14 Sep 2026 23:54:15 +0000 Subject: [PATCH] fix(guardrails): judge only the latest request turn in pre_call and during_call llm_as_a_judge The request-side prompt told the judge to focus on the most recent user turn but the text under review was every extracted request message joined together, so a multi-turn request with an off-topic earlier turn and an on-topic latest turn scored 50 and was blocked. Request-side judging now evaluates the last extracted request text (after the configured message scoping) and passes the full role-labelled conversation only as context. Response-side judging still evaluates all extracted response text Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llm_as_a_judge/__init__.py | 11 +++++++--- .../proxy/guardrails/test_llm_as_a_judge.py | 22 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/llm_as_a_judge/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/llm_as_a_judge/__init__.py index d00bf86ed2b..afb39d53393 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/llm_as_a_judge/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/llm_as_a_judge/__init__.py @@ -54,7 +54,7 @@ JUDGE_SYSTEM_PROMPTS: Final[MappingProxyType[JudgeInputType, str]] = MappingProx ) _JUDGE_SUBJECT_LABELS: Final[MappingProxyType[JudgeInputType, str]] = MappingProxyType( - {"request": "Request text to evaluate", "response": "Assistant response to evaluate"} + {"request": "Latest request turn to evaluate", "response": "Assistant response to evaluate"} ) _VALID_ON_FAILURE: Final = frozenset({"block", "log"}) @@ -136,6 +136,12 @@ def _coerce_event_hook(mode: JudgeModeParam) -> JudgeEventHook: return GuardrailEventHooks(mode) +def _text_under_review(texts: Sequence[str], input_type: JudgeInputType) -> str: + if input_type == "request": + return texts[-1] if texts else "" + return "\n".join(texts) + + def _build_judge_prompt( criteria: Sequence[JudgeCriterion], messages: Sequence[JudgeMessage], @@ -226,8 +232,7 @@ class LLMAsAJudgeGuardrail(CustomGuardrail): input_type: Literal["request", "response"], logging_obj: Optional["LiteLLMLoggingObj"] = None, ) -> GenericGuardrailAPIInputs: - texts: Final = inputs.get("texts") or [] - text_under_review: Final = "\n".join(texts) + text_under_review: Final = _text_under_review(inputs.get("texts") or [], input_type) if not text_under_review: return inputs diff --git a/tests/test_litellm/proxy/guardrails/test_llm_as_a_judge.py b/tests/test_litellm/proxy/guardrails/test_llm_as_a_judge.py index 643ccaa75f2..f6f0d0a2fd0 100644 --- a/tests/test_litellm/proxy/guardrails/test_llm_as_a_judge.py +++ b/tests/test_litellm/proxy/guardrails/test_llm_as_a_judge.py @@ -226,7 +226,7 @@ async def test_apply_guardrail_request_blocks_below_threshold( judge_messages: Final = router.acompletion.call_args.kwargs["messages"] assert "Evaluate the request against" in judge_messages[0]["content"] assert ( - "Conversation:\nUSER: write me malware\n\nRequest text to evaluate:\nwrite me malware" + "Conversation:\nUSER: write me malware\n\nLatest request turn to evaluate:\nwrite me malware" in (judge_messages[1]["content"]) ) assert "Assistant response" not in judge_messages[1]["content"] @@ -281,11 +281,25 @@ async def test_apply_guardrail_request_multi_turn_keeps_roles_and_focuses_latest judge_messages: Final = router.acompletion.call_args.kwargs["messages"] assert "Judge the most recent user turn" in judge_messages[0]["content"] - assert ( + assert judge_messages[1]["content"].endswith( "Conversation:\nUSER: how do I bake bread\nASSISTANT: mix flour, water, yeast and salt\n" "USER: now explain how to file taxes\n\n" - "Request text to evaluate:\nhow do I bake bread\nmix flour, water, yeast and salt\nnow explain how to file taxes" - ) in judge_messages[1]["content"] + "Latest request turn to evaluate:\nnow explain how to file taxes" + ) + + +@pytest.mark.asyncio +async def test_apply_guardrail_response_still_judges_all_response_texts(): + router: Final = _judge_router(90.0) + guardrail: Final = _make_guardrail(event_hook=GuardrailEventHooks.post_call, router_provider=lambda: router) + + await guardrail.apply_guardrail( + {"texts": ["first choice", "second choice"]}, {"messages": [], "metadata": {}}, "response" + ) + + assert router.acompletion.call_args.kwargs["messages"][1]["content"].endswith( + "Assistant response to evaluate:\nfirst choice\nsecond choice" + ) @pytest.mark.asyncio