fix(guardrails): read Prompt Security modified rows with the slot count's own predicate

A chat row whose content carried an empty text part counted two slots in the
chat completions handler while Prompt Security read one text out of the
modified row, so the structured rewrite was dropped and the request got the
named rejection. One shared helper now lists a row's slot texts and both the
slot count and the modified-row reader use it.
This commit is contained in:
mateo-berri 2026-09-14 21:31:22 -07:00
parent 16c326537f
commit e01d97ea08
3 changed files with 34 additions and 18 deletions

View file

@ -373,13 +373,17 @@ def _content_part_text(part: object) -> str | None:
return text if isinstance(text, str) else None
def message_text_slot_count(message: AllMessageValues) -> int:
def message_slot_texts(message: Mapping[str, object]) -> tuple[str, ...]:
content: Final = message.get("content")
if isinstance(content, str):
return 1
return (content,)
if isinstance(content, list):
return sum(1 for part in content if _content_part_text(part) is not None)
return 0
return tuple(text for part in content if (text := _content_part_text(part)) is not None)
return ()
def message_text_slot_count(message: AllMessageValues) -> int:
return len(message_slot_texts(message))
def _part_with_text(part: object, text: str) -> object:

View file

@ -15,7 +15,7 @@ from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.llms.base_llm.guardrail_translation.utils import message_with_slot_texts
from litellm.llms.base_llm.guardrail_translation.utils import message_slot_texts, message_with_slot_texts
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
@ -399,19 +399,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
return inputs
def _extract_texts_from_messages(self, messages: Sequence[Mapping[str, object]]) -> list[str]:
"""Extract text content from messages."""
texts: Final = []
for message in messages:
content = message.get("content")
if isinstance(content, str):
texts.append(content)
elif isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text = item.get("text")
if text:
texts.append(text)
return texts
return [text for message in messages for text in message_slot_texts(message)]
async def _process_standalone_images(self, images: list[str], user_api_key_alias: str | None) -> None:
"""Process standalone images from inputs (data URLs)."""

View file

@ -269,6 +269,30 @@ async def test_modify_with_unexpected_message_count_keeps_texts_only(monkeypatch
assert result["texts"] == ["Look up [REDACTED]"]
@pytest.mark.asyncio
async def test_modify_keeps_empty_text_parts_as_slots(monkeypatch: pytest.MonkeyPatch):
"""The chat handler counts an empty text part as a slot, so a modify verdict
that echoes the empty part still lines up with the row and its texts."""
monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key")
monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security")
guardrail = PromptSecurityGuardrail(guardrail_name="test-guard", event_hook="pre_call", default_on=True)
messages: list[AllMessageValues] = [
{"role": "user", "content": [{"type": "text", "text": "Look up 123-45-6789"}, {"type": "text", "text": ""}]}
]
inputs = {"texts": ["Look up 123-45-6789", ""], "structured_messages": messages}
modified_messages = [
{"role": "user", "content": [{"type": "text", "text": "Look up [REDACTED]"}, {"type": "text", "text": ""}]}
]
with patch.object(guardrail.async_handler, "post", return_value=_modify_response(modified_messages)):
result = await guardrail.apply_guardrail(
inputs=inputs, request_data={"messages": messages}, input_type="request"
)
assert result["structured_messages"] == modified_messages
assert result["texts"] == ["Look up [REDACTED]", ""]
@pytest.mark.asyncio
async def test_apply_guardrail_allow_request(monkeypatch: pytest.MonkeyPatch):
"""Test that apply_guardrail allows safe prompts"""