fix(guardrails): restore llm shield values in anthropic replies

The /v1/messages reply is a plain dict with a content block list and no
choices, so it fell through the restore path and went back to the caller
still carrying placeholders. The request was redacted correctly, which is
what made this easy to miss.

Found by running all three endpoints against a live provider; the mocked
tests all passed because they only built the OpenAI shape. Adds tests for
the message shape and for leaving non-text blocks alone.
This commit is contained in:
Ninad Phalak 2026-09-03 14:23:14 -05:00
parent 5fccbfe49f
commit ee0bac5148
No known key found for this signature in database
GPG key ID: 59119ED515433744
2 changed files with 88 additions and 1 deletions

View file

@ -227,6 +227,9 @@ class LLMShieldGuardrail(CustomGuardrail):
if self.should_run_guardrail(data=data, event_type=GuardrailEventHooks.post_call) is not True:
return response
if self._is_anthropic_message_response(response):
return await self._restore_anthropic_response(response, data)
choices = getattr(response, "choices", None)
if not choices:
return response
@ -246,6 +249,34 @@ class LLMShieldGuardrail(CustomGuardrail):
message.content = replacement
return response
@staticmethod
def _is_anthropic_message_response(response: Any) -> bool:
"""Anthropic's native /v1/messages reply arrives as a plain dict."""
return (
isinstance(response, dict)
and response.get("type") == "message"
and isinstance(response.get("content"), list)
)
async def _restore_anthropic_response(self, response: dict, data: dict) -> dict:
"""Restores text blocks in an Anthropic native message reply.
This shape has no `choices`, so without its own branch the reply would go
back to the caller still carrying placeholders.
"""
blocks = [
block
for block in response["content"]
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str)
]
if not blocks:
return response
restored = await self._rehydrate([block["text"] for block in blocks], self._session_id(data))
for block, replacement in zip(blocks, restored):
block["text"] = replacement
return response
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,

View file

@ -11,7 +11,7 @@ from litellm.proxy.guardrails.guardrail_hooks.llm_shield.llm_shield import (
)
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
from litellm.types.utils import Choices, Delta, Message, ModelResponse, ModelResponseStream, StreamingChoices
def _guardrail(**overrides: object) -> LLMShieldGuardrail:
@ -156,6 +156,62 @@ class TestRedaction:
assert len(sessions) == 1
class TestRestoration:
@pytest.mark.asyncio
async def test_openai_shape_is_restored(self):
guardrail = _guardrail(event_hook="post_call")
_mock_post(guardrail, {"texts": ["a@b.com"]})
response = ModelResponse(choices=[Choices(index=0, message=Message(role="assistant", content="[EMAIL_1]"))])
result = await guardrail.async_post_call_success_hook(
data={"messages": []}, user_api_key_dict=None, response=response
)
assert result.choices[0].message.content == "a@b.com"
@pytest.mark.asyncio
async def test_anthropic_message_shape_is_restored(self):
"""The /v1/messages reply is a plain dict with no choices.
Measured against a live provider: without its own branch the reply went
back to the caller still carrying the placeholder, even though the
request had been redacted correctly.
"""
guardrail = _guardrail(event_hook="post_call")
_mock_post(guardrail, {"texts": ["a@b.com"]})
response = {
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "[EMAIL_1]"}],
}
result = await guardrail.async_post_call_success_hook(
data={"messages": []}, user_api_key_dict=None, response=response
)
assert result["content"][0]["text"] == "a@b.com"
@pytest.mark.asyncio
async def test_anthropic_non_text_blocks_are_left_alone(self):
guardrail = _guardrail(event_hook="post_call")
_mock_post(guardrail, {"texts": ["a@b.com"]})
response = {
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "[EMAIL_1]"},
{"type": "tool_use", "id": "t1", "name": "lookup", "input": {}},
],
}
result = await guardrail.async_post_call_success_hook(
data={"messages": []}, user_api_key_dict=None, response=response
)
assert result["content"][0]["text"] == "a@b.com"
assert result["content"][1] == {"type": "tool_use", "id": "t1", "name": "lookup", "input": {}}
class TestFailClosed:
@pytest.mark.asyncio
async def test_unreachable_shield_blocks_the_request(self):