From 119ec629529a43a0e3504650d5458170a7a2dc17 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Fri, 4 Sep 2026 02:35:32 -0500 Subject: [PATCH] fix(guardrails): redact Responses PromptObject variables A Responses request can send `prompt` as a PromptObject rather than a string. Its `variables` are substituted into the stored prompt on the provider side, so they are caller text, and the dict shape was falling through untouched. `id` and `version` pick which stored prompt to run and are left unchanged. --- .../llm_shield_proxy/llm_shield_proxy.py | 9 +++++++++ .../guardrail_hooks/test_llm_shield_proxy.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py b/litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py index 347b70ecd75..dea06cafab4 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py +++ b/litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py @@ -110,6 +110,15 @@ def _collect_prompt(data: MutableRequest, slots: _SlotSink) -> None: if isinstance(prompt, str): _collect(data, "prompt", slots) return + if isinstance(prompt, dict): + # A Responses API PromptObject. `variables` are substituted into the stored + # prompt on the provider side, so they are caller text. `id` and `version` + # identify which prompt to use and must arrive unchanged. + variables: Final = prompt.get("variables") + if isinstance(variables, dict): + for name in tuple(variables): + _collect(variables, name, slots) + return if not isinstance(prompt, list): return for index in range(len(prompt)): diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_llm_shield_proxy.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_llm_shield_proxy.py index a49bc5b1275..b056756ac15 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_llm_shield_proxy.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_llm_shield_proxy.py @@ -401,6 +401,23 @@ class TestRequestCoverage: await guardrail.async_pre_call_hook(user_api_key_dict=None, cache=None, data=data, call_type="completion") + @pytest.mark.asyncio + async def test_responses_prompt_object_variables_are_redacted(self): + """A PromptObject's variables are substituted into the prompt provider side. + + The id and version pick which stored prompt to run and have to arrive + unchanged; the variables are caller text. + """ + guardrail = _guardrail() + _mock_post(guardrail, {"texts": ["[EMAIL_1]"]}) + + data = {"prompt": {"id": "pmpt_123", "version": "2", "variables": {"customer": "jane.doe@example.com"}}} + await guardrail.async_pre_call_hook(user_api_key_dict=None, cache=None, data=data, call_type="aresponses") + + assert data["prompt"]["variables"]["customer"] == "[EMAIL_1]" + assert data["prompt"]["id"] == "pmpt_123" + assert data["prompt"]["version"] == "2" + @pytest.mark.asyncio async def test_completions_suffix_is_redacted(self): """LiteLLM forwards the legacy `suffix` to providers that support it."""