This commit is contained in:
ysimidjiyski-gs 2026-08-26 21:02:52 -04:00 committed by GitHub
commit e1ccbd6488
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1280 additions and 39 deletions

View file

@ -111,6 +111,17 @@ class ExtractedInput:
EMPTY_EXTRACTED_INPUT: Final = ExtractedInput(scanned=(), images=())
@dataclass(frozen=True, slots=True)
class _ScopedRequestView:
"""The request as guardrail scans see it, with the operator scoping applied."""
translated_request: ChatCompletionRequest
full_messages: tuple[AllMessageValues, ...]
scoped_indices: tuple[int, ...]
hoisted_system_message: AllMessageValues | None
has_midturn_system_message: bool
class AnthropicMessagesHandler(BaseTranslation):
"""Process Anthropic messages with guardrails.
@ -314,6 +325,78 @@ class AnthropicMessagesHandler(BaseTranslation):
)
return result if result else None
def _scoped_request_view(
self,
data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> "_ScopedRequestView":
"""
Translate the request the way guardrail scans see it: exclude the
trusted top-level prompt, hoist it back unless skip_system, keep
in-sequence system entries in scope. Single source of truth for the
request and response scans, which must not disagree.
"""
skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply)
translation_source: Final = { # mutable-ok: API message payload
**{key: value for key, value in data.items() if key != "system"},
"model": data.get("model") or "",
}
translated_request: Final = self._translate_to_openai(translation_source)
translated_messages: Final = tuple(translated_request.get("messages") or ())
has_midturn_system_message: Final = any(
str(message.get("role") or "").lower() == "system" for message in translated_messages
)
hoisted_system_message: Final = None if skip_system else self._hoisted_top_level_system_message(data)
full_messages: Final = (
(hoisted_system_message, *translated_messages)
if hoisted_system_message is not None
else translated_messages
)
# skip_system already excluded the trusted top-level prompt (it is simply
# not hoisted); in-sequence system entries are untrusted and stay in scope.
scoped_indices: Final = scoped_structured_message_indices(
full_messages,
scan_only_tool_results=effective_scan_only_tool_results_for_guardrail(guardrail_to_apply),
skip_system=False,
skip_tool=effective_skip_tool_message_for_guardrail(guardrail_to_apply),
)
return _ScopedRequestView(
translated_request=translated_request,
full_messages=full_messages,
scoped_indices=scoped_indices,
hoisted_system_message=hoisted_system_message,
has_midturn_system_message=has_midturn_system_message,
)
def scoped_request_conversation(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple[AllMessageValues, ...] | None:
if request_data.get("messages") is None:
return None
view: Final = self._scoped_request_view(request_data, guardrail_to_apply)
return tuple(view.full_messages[index] for index in view.scoped_indices) or None
def request_tools_for_guardrail(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple[ChatCompletionToolParam, ...] | None:
if effective_scan_only_tool_results_for_guardrail(guardrail_to_apply):
return None
if not request_data.get("tools"):
return None
probe: Final = self._translate_to_openai(
{ # mutable-ok: API message payload
"model": request_data.get("model") or "",
"messages": [], # mutable-ok: API message payload
"tools": request_data["tools"],
}
)
tools: Final = probe.get("tools")
return tuple(tools) if tools else None
async def process_input_messages(
self,
data: dict,
@ -334,33 +417,17 @@ class AnthropicMessagesHandler(BaseTranslation):
# Exclude only the trusted top-level prompt. In-sequence system entries are untrusted
# and must stay aligned with texts_to_check for positional masking. When the top-level
# prompt is included, the pre-existing count mismatch disables positional masking.
translation_source: Final = { # mutable-ok: API message payload
key: value for key, value in data.items() if key != "system"
}
chat_completion_compatible_request: Final = self._translate_to_openai(translation_source)
view: Final = self._scoped_request_view(data, guardrail_to_apply)
full_structured_messages: Final = view.full_messages
has_midturn_system_message: Final = view.has_midturn_system_message
hoisted_system_message: Final = view.hoisted_system_message
scoped_message_indices: Final = view.scoped_indices
structured_messages: Final = [ # mutable-ok: GenericGuardrailAPIInputs takes list
full_structured_messages[index] for index in scoped_message_indices
]
full_structured_messages: Final = cast(
list[AllMessageValues],
chat_completion_compatible_request.get("messages", []),
)
has_midturn_system_message: Final = any(
str(message.get("role") or "").lower() == "system" for message in full_structured_messages
)
hoisted_system_message: Final = None if skip_system else self._hoisted_top_level_system_message(data)
if hoisted_system_message is not None:
full_structured_messages.insert(0, hoisted_system_message)
# skip_system already excluded the trusted top-level prompt (it is simply not hoisted);
# in-sequence system entries are untrusted and always stay in scope.
scoped_message_indices: Final = scoped_structured_message_indices(
full_structured_messages,
scan_only_tool_results=scan_only_tool_results,
skip_system=False,
skip_tool=skip_tool,
)
structured_messages: Final = [full_structured_messages[index] for index in scoped_message_indices]
tools_to_check: Final[list[ChatCompletionToolParam]] = (
[] if scan_only_tool_results else chat_completion_compatible_request.get("tools", [])
tools_to_check: Final[list[ChatCompletionToolParam]] = ( # mutable-ok: GenericGuardrailAPIInputs takes list
[] if scan_only_tool_results else list(view.translated_request.get("tools") or ())
)
# Step 1: Extract all text content and images
@ -896,6 +963,13 @@ class AnthropicMessagesHandler(BaseTranslation):
response,
)
self.attach_response_scan_context(
inputs,
request_data,
guardrail_to_apply,
self.assistant_turn_from_extraction(texts_to_check, tool_calls_to_check),
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
@ -962,6 +1036,23 @@ class AnthropicMessagesHandler(BaseTranslation):
user_api_key_dict,
key="response",
)
stream_tool_calls: Final = tuple(
self.assistant_tool_call(
tool_call_id=tool_call.id,
name=tool_call.function.name,
arguments=tool_call.function.arguments,
)
for tool_call in tool_calls_list or ()
)
self.attach_response_scan_context(
guardrail_inputs,
prepared_request_data,
guardrail_to_apply,
self.assistant_turn_from_extraction(
(string_so_far,) if isinstance(string_so_far, str) and string_so_far else (),
stream_tool_calls,
),
)
_guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
inputs=guardrail_inputs,
request_data=prepared_request_data,

View file

@ -1,7 +1,20 @@
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final, Optional
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_scan_only_tool_results_for_guardrail,
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
scoped_structured_message_indices,
)
from litellm.types.llms.openai import (
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionToolCallFunctionChunk,
)
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
@ -9,7 +22,12 @@ if TYPE_CHECKING:
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionToolCallChunk,
ChatCompletionToolParam,
)
from litellm.types.utils import GenericGuardrailAPIInputs
@dataclass(slots=True)
@ -157,6 +175,140 @@ class BaseTranslation(ABC):
"""
return None
def scoped_request_conversation(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple["AllMessageValues", ...] | None:
"""
The request conversation as the guardrail's request scan saw it: the
handler's structured messages with the operator scoping flags applied.
Override when the request scan scopes differently (e.g. Anthropic's
top-level system prompt hoisting).
"""
structured_messages: Final = self.get_structured_messages(request_data)
if not structured_messages:
return None
scoped_indices: Final = scoped_structured_message_indices(
structured_messages,
scan_only_tool_results=effective_scan_only_tool_results_for_guardrail(guardrail_to_apply),
skip_system=effective_skip_system_message_for_guardrail(guardrail_to_apply),
skip_tool=effective_skip_tool_message_for_guardrail(guardrail_to_apply),
)
return tuple(structured_messages[index] for index in scoped_indices) or None
def response_scan_conversation(
self,
request_data: dict | None, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
response_turns: Sequence["AllMessageValues"],
) -> tuple["AllMessageValues", ...] | None:
"""
Full conversation for a response scan: the scoped request conversation
with the model's response turns appended.
Returns None when the request context is unavailable (SDK/direct-call
path fabricates request_data without messages) or when nothing was
extracted from the response; guardrails then fall back to scanning the
extracted texts and tool calls, which must not be shadowed by a
conversation that lacks a response turn.
"""
if request_data is None or not response_turns:
return None
request_conversation: Final = self.scoped_request_conversation(request_data, guardrail_to_apply)
if request_conversation is None:
return None
return (*request_conversation, *response_turns)
def attach_response_scan_context(
self,
inputs: "GenericGuardrailAPIInputs",
request_data: dict | None, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
response_turns: Sequence["AllMessageValues"],
) -> None:
"""
Put the response-scan conversation and the request's tools on ``inputs``
when the request context allows building them; no-op otherwise.
"""
structured_conversation: Final = self.response_scan_conversation(
request_data, guardrail_to_apply, response_turns
)
if structured_conversation is None or request_data is None:
return
inputs["structured_messages"] = list(structured_conversation) # rebind-ok: out-param; field type is list
response_scan_tools: Final = self.request_tools_for_guardrail(request_data, guardrail_to_apply)
if response_scan_tools:
inputs["tools"] = list(response_scan_tools) # rebind-ok: out-parameter; the field type is a list
def request_tools_for_guardrail(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple["ChatCompletionToolParam", ...] | None:
"""
The request's tool definitions in the shape the request scan sends them.
Override in tool-capable handlers; default returns None.
"""
return None
@staticmethod
def assistant_tool_call(
tool_call_id: str | None,
name: str | None,
arguments: str,
) -> ChatCompletionAssistantToolCall:
"""One assistant-message tool call in the OpenAI wire shape."""
return ChatCompletionAssistantToolCall(
id=tool_call_id,
type="function",
function=ChatCompletionToolCallFunctionChunk(name=name, arguments=arguments),
)
@staticmethod
def assistant_turn(
content: str | None,
tool_calls: Sequence[ChatCompletionAssistantToolCall],
) -> ChatCompletionAssistantMessage | None:
"""One OpenAI-shape assistant turn, or None when it would carry nothing."""
if content is None and not tool_calls:
return None
if tool_calls:
turn_with_tools: Final[ChatCompletionAssistantMessage] = {
"role": "assistant",
"content": content,
"tool_calls": list(tool_calls), # mutable-ok: the message field type is a list
}
return turn_with_tools
turn: Final[ChatCompletionAssistantMessage] = {"role": "assistant", "content": content}
return turn
@staticmethod
def assistant_turn_from_extraction(
texts: Sequence[str],
tool_calls: Sequence["ChatCompletionAssistantToolCall | ChatCompletionToolCallChunk"] | None = None,
) -> tuple["ChatCompletionAssistantMessage", ...]:
"""
One OpenAI-shape assistant turn built from the texts and tool calls a
handler's response extraction collected; empty when there is nothing.
Tool calls are normalized to the assistant-message shape, dropping
extraction-only fields such as ``index``.
"""
tool_call_items: Final = tuple(
BaseTranslation.assistant_tool_call(
tool_call_id=item.get("id"),
name=item["function"].get("name"),
arguments=item["function"].get("arguments") or "",
)
for item in tool_calls or ()
)
if not texts and not tool_call_items:
return ()
turn: Final = BaseTranslation.assistant_turn("\n".join(texts), tool_call_items)
return (turn,) if turn is not None else ()
def extract_request_tool_names(self, data: dict) -> list[str]:
"""
Extract tool names from the request body for allowlist/policy checks.

View file

@ -33,7 +33,11 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
scoped_structured_message_indices,
)
from litellm.main import stream_chunk_builder
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionToolParam,
)
from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import (
coerce_stream_holdback_value,
)
@ -396,6 +400,10 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
if hasattr(response, "model") and response.model:
inputs["model"] = response.model
self.attach_response_scan_context(
inputs, request_data, guardrail_to_apply, self._build_response_turns(response)
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
@ -566,6 +574,9 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
# Include model information from the first response if available
if responses_so_far and hasattr(responses_so_far[0], "model") and responses_so_far[0].model:
inputs["model"] = responses_so_far[0].model
self.attach_response_scan_context(
inputs, request_data, guardrail_to_apply, self.assistant_turn_from_extraction(texts_to_check)
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
@ -660,6 +671,12 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
inputs: Final = GenericGuardrailAPIInputs(texts=texts_to_check)
if responses_so_far and getattr(responses_so_far[0], "model", None):
inputs["model"] = responses_so_far[0].model
self.attach_response_scan_context(
inputs,
request_data,
guardrail_to_apply,
tuple(turn for index in indices if (turn := self.assistant_turn(raw_by_index[index], ())) is not None),
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
@ -832,6 +849,32 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
tool_calls_to_check.append(tool_call_dict)
tool_call_task_mappings.append((choice_idx, int(tool_call_idx)))
def request_tools_for_guardrail(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple[ChatCompletionToolParam, ...] | None:
if effective_scan_only_tool_results_for_guardrail(guardrail_to_apply):
return None
tools: Final = request_data.get("tools")
return tuple(tools) if tools else None
def _build_response_turns(self, response: "ModelResponse") -> tuple[ChatCompletionAssistantMessage, ...]:
"""Assistant turns for the response-scan conversation, one per choice."""
return tuple(turn for choice in response.choices if (turn := self._choice_assistant_turn(choice)) is not None)
def _choice_assistant_turn(self, choice: Choices) -> ChatCompletionAssistantMessage | None:
tool_calls: Final = tuple(
self.assistant_tool_call(
tool_call_id=converted.get("id"),
name=(converted.get("function") or {}).get("name"),
arguments=(converted.get("function") or {}).get("arguments") or "",
)
for tool_call in choice.message.tool_calls or ()
if (converted := self._convert_tool_call_to_dict(tool_call)) is not None and converted.get("function")
)
return self.assistant_turn(choice.message.content, tool_calls)
def _convert_tool_call_to_dict(self, tool_call: dict[str, Any] | Any) -> dict[str, Any] | None:
"""
Convert a tool call object to dictionary format.

View file

@ -248,6 +248,32 @@ class OpenAIResponsesHandler(BaseTranslation):
) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools)
tools_to_check.extend(cast(list[ChatCompletionToolParam], transformed_tools))
def scoped_request_conversation(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple[AllMessageValues, ...] | None:
"""
This surface's request scan sends its structured messages without the
operator scoping flags (the input path applies none), so the response
scan mirrors that; request and response scans must not disagree about
the conversation.
"""
structured_messages: Final = self.get_structured_messages(request_data)
return tuple(structured_messages) if structured_messages else None
def request_tools_for_guardrail(
self,
request_data: dict, # mutable-ok: API request payload
guardrail_to_apply: "CustomGuardrail",
) -> tuple[ChatCompletionToolParam, ...] | None:
raw_tools: Final = request_data.get("tools")
if not raw_tools:
return None
tools_to_check: Final[list[ChatCompletionToolParam]] = [] # mutable-ok: _extract_and_transform_tools appends
self._extract_and_transform_tools(raw_tools, tools_to_check)
return tuple(tools_to_check) or None
def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, object]]:
"""
Remap guardrail-returned tools (Chat Completion format) back to
@ -455,6 +481,13 @@ class OpenAIResponsesHandler(BaseTranslation):
if response_model:
inputs["model"] = response_model
self.attach_response_scan_context(
inputs,
request_data,
guardrail_to_apply,
self.assistant_turn_from_extraction(texts_to_check, tool_calls_to_check),
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
@ -545,6 +578,13 @@ class OpenAIResponsesHandler(BaseTranslation):
if response_model:
inputs["model"] = response_model
self.attach_response_scan_context(
inputs,
request_data,
guardrail_to_apply,
self.assistant_turn_from_extraction(texts_to_check, tool_calls_to_check),
)
guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,

View file

@ -162,12 +162,19 @@ class AktoGuardrail(CustomGuardrail):
def build_request_body(
inputs: GenericGuardrailAPIInputs,
request_data: dict | None = None,
*,
prefer_structured_messages: bool = True,
) -> dict[str, Any]:
"""Build the LLM request body from guardrail inputs (messages, model, tools)."""
"""Build the LLM request body from guardrail inputs (messages, model, tools).
``prefer_structured_messages`` is False on response scans, where
``structured_messages`` carries the response turns too and would
misrepresent the request in the ingested payload.
"""
model: Final = inputs.get("model", "") or ""
body: Final[dict[str, Any]] = {"model": model}
structured: Final = inputs.get("structured_messages")
structured: Final = inputs.get("structured_messages") if prefer_structured_messages else None
if structured:
body["messages"] = structured
elif request_data is not None and request_data.get("messages"):
@ -232,7 +239,9 @@ class AktoGuardrail(CustomGuardrail):
"""
request_path: Final = self.extract_request_path(request_data)
request_headers: Final = self.build_request_headers(request_data)
request_body: Final = self.build_request_body(inputs, request_data)
request_body: Final = self.build_request_body(
inputs, request_data, prefer_structured_messages=not include_response
)
tag: Final = self.build_tag_metadata(request_data)
response_payload = json.dumps({}) # Empty body wrapper when no response yet

View file

@ -211,7 +211,9 @@ class HiddenlayerGuardrail(CustomGuardrail):
hl_request_metadata["requester_id"] = headers.get("hl-requester-id") or "LiteLLM"
project_id: Final = headers.get("hl-project-id")
if scan_params := inputs.get("structured_messages"):
# Response scans keep the texts path: their structured_messages carry the
# whole conversation, whose last turn is not necessarily the scan target.
if input_type == "request" and (scan_params := inputs.get("structured_messages")):
last_msg: Final = scan_params[-1]
result: _HiddenlayerResponse = await self._call_hiddenlayer(
project_id,

View file

@ -195,8 +195,10 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail):
# Extract text to moderate from inputs
text_to_moderate: str | None = None
# Prefer structured_messages if available (has role context)
if structured_messages := inputs.get("structured_messages"):
# Prefer structured_messages if available (has role context). Response
# scans moderate the model output via texts; the conversation would
# point moderation back at the user prompt.
if input_type == "request" and (structured_messages := inputs.get("structured_messages")):
text_to_moderate = self.get_user_prompt(structured_messages)
# Fall back to texts

View file

@ -105,7 +105,11 @@ class PromptGuardGuardrail(CustomGuardrail):
structured_messages: Final = inputs.get("structured_messages", [])
model: Final = inputs.get("model")
if structured_messages:
# Response scans keep the texts path: the redact write-back extracts
# user-role texts, which would clobber the response texts if the
# conversation were sent instead.
use_structured: Final = input_type == "request" and bool(structured_messages)
if use_structured:
messages = list(structured_messages)
elif texts:
messages = [{"role": "user", "content": text} for text in texts]
@ -175,7 +179,7 @@ class PromptGuardGuardrail(CustomGuardrail):
if decision == "redact":
redacted: Final = result.get("redacted_messages")
if redacted:
if structured_messages:
if use_structured:
inputs["structured_messages"] = redacted
if "texts" in inputs:
extracted: Final = self._extract_texts_from_messages(

View file

@ -424,8 +424,10 @@ class QualifireGuardrail(CustomGuardrail):
# Get dynamic params from request body (allows runtime overrides)
dynamic_params: Final = self.get_guardrail_dynamic_request_body_params(request_data=request_data)
# Extract messages from structured_messages or request_data
messages: list[AllMessageValues] | None = inputs.get("structured_messages")
# Extract messages from structured_messages or request_data. Response
# scans keep the request_data path: their structured_messages carry the
# model's answer too, which would land in `messages` on top of `output`.
messages: list[AllMessageValues] | None = inputs.get("structured_messages") if input_type == "request" else None
if not messages:
messages = request_data.get("messages")

View file

@ -380,10 +380,15 @@ class StraikerGuardrail(CustomGuardrail):
call_id: Final = getattr(logging_obj, "litellm_call_id", None) if logging_obj else None
event_id: Final = f"{call_id or 'litellm'}:{input_type}"
# On response scans this content object is the envelope's ``response``,
# while ``structured_messages`` carries the whole conversation; it is
# reported under ``request`` instead so neither side misattributes turns.
content: Final = StraikerWebhookContent(
texts=list(inputs.get("texts") or []),
images=list(inputs.get("images") or []),
structured_messages=_opaque_dict_list(inputs.get("structured_messages")),
structured_messages=(
_opaque_dict_list(inputs.get("structured_messages")) if input_type == "request" else None
),
tools=_opaque_dict_list(inputs.get("tools")),
tool_calls=_opaque_dict_list(inputs.get("tool_calls")),
)

View file

@ -1818,3 +1818,204 @@ class TestAnthropicMessagesScanOnlyToolResults:
assert guardrail.captured_inputs is not None
assert guardrail.captured_inputs.get("images") == ["TOOL_IMG"]
class MockInputsRecordingGuardrail(CustomGuardrail):
"""Records the inputs of every apply_guardrail call without modifying anything."""
def __init__(self):
super().__init__(guardrail_name="inputs-recording")
self.calls: list[tuple[str, dict]] = []
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
import copy
self.calls.append((input_type, copy.deepcopy(dict(inputs))))
return inputs
class TestAnthropicResponseScanConversation:
"""Response scans receive the request conversation (with the top-level system
prompt hoisted, mirroring the request scan) plus the model's assistant turn,
and the request's tools translated to OpenAI shape."""
def _request_data(self, **overrides) -> dict:
data = {
"model": "claude-sonnet-4-5",
"system": "you are a helpful assistant",
"messages": [{"role": "user", "content": "what's the weather?"}],
**overrides,
}
return {key: value for key, value in data.items() if value is not None}
@pytest.mark.asyncio
async def test_response_scan_receives_hoisted_system_and_assistant_turn(self):
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Sunny today."}],
}
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data=self._request_data(),
)
input_type, inputs = guardrail.calls[-1]
assert input_type == "response"
conversation = inputs["structured_messages"]
assert conversation[0]["role"] == "system"
assert "helpful assistant" in str(conversation[0]["content"])
assert conversation[1] == {"role": "user", "content": "what's the weather?"}
assert conversation[-1] == {"role": "assistant", "content": "Sunny today."}
@pytest.mark.asyncio
async def test_skip_system_excludes_hoisted_prompt_from_response_scan(self):
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
guardrail.skip_system_message_in_guardrail = True
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Sunny today."}],
}
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data=self._request_data(),
)
_, inputs = guardrail.calls[-1]
conversation = inputs["structured_messages"]
assert all(message["role"] != "system" for message in conversation)
assert conversation[0] == {"role": "user", "content": "what's the weather?"}
assert conversation[-1] == {"role": "assistant", "content": "Sunny today."}
@pytest.mark.asyncio
async def test_skip_system_scoping_matches_request_scan(self):
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
guardrail.skip_system_message_in_guardrail = True
request_data = self._request_data(
system="trusted top-level system prompt",
messages=[
{"role": "user", "content": "safe text"},
{"role": "system", "content": "prohibited correction"},
{"role": "user", "content": "continue"},
],
)
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Done."}],
}
await handler.process_input_messages(data=dict(request_data), guardrail_to_apply=guardrail)
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data=dict(request_data),
)
(request_type, request_inputs), (response_type, response_inputs) = guardrail.calls
assert (request_type, response_type) == ("request", "response")
request_conversation = request_inputs["structured_messages"]
response_conversation = response_inputs["structured_messages"]
assert [message["role"] for message in request_conversation] == ["user", "system", "user"]
assert response_conversation[: len(request_conversation)] == request_conversation
assert response_conversation[len(request_conversation) :] == [{"role": "assistant", "content": "Done."}]
assert "trusted top-level system prompt" not in str(response_conversation)
@pytest.mark.asyncio
async def test_response_scan_includes_translated_tools_and_tool_call_turn(self):
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
request_data = self._request_data(
system=None,
tools=[
{
"name": "get_weather",
"description": "look up weather",
"input_schema": {"type": "object", "properties": {}},
}
],
)
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [
{"type": "text", "text": "Checking."},
{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Paris"}},
],
}
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data=request_data,
)
_, inputs = guardrail.calls[-1]
assistant_turn = inputs["structured_messages"][-1]
assert assistant_turn["role"] == "assistant"
assert assistant_turn["content"] == "Checking."
assert assistant_turn["tool_calls"][0]["function"]["name"] == "get_weather"
assert "index" not in assistant_turn["tool_calls"][0], (
"extraction-only fields must not leak into the assistant-message tool call shape"
)
assert inputs["tools"][0]["function"]["name"] == "get_weather"
@pytest.mark.asyncio
async def test_response_scan_without_request_messages_sends_no_conversation(self):
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Hello."}],
}
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data=None,
)
_, inputs = guardrail.calls[-1]
assert "structured_messages" not in inputs
assert inputs["texts"] == ["Hello."]
@pytest.mark.asyncio
async def test_response_scan_survives_request_data_without_model(self):
"""Buffered streaming hands the response scan a request payload with no
``model``; the conversation must still be built rather than blowing up
inside the Anthropic-to-OpenAI adapter."""
handler = AnthropicMessagesHandler()
guardrail = MockInputsRecordingGuardrail()
response = {
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Sunny today."}],
}
await handler.process_output_response(
response=response,
guardrail_to_apply=guardrail,
request_data={"messages": [{"role": "user", "content": "what's the weather?"}]},
)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "user", "content": "what's the weather?"},
{"role": "assistant", "content": "Sunny today."},
]

View file

@ -1559,3 +1559,300 @@ class TestScanOnlyToolResults:
assert data["messages"][3]["content"] == "page says [BLOCKED] here"
assert data["messages"][3]["tool_call_id"] == "call_1"
assert data["messages"][4]["content"] == "and then?"
class RecordingGuardrail(CustomGuardrail):
"""Captures the inputs of every apply_guardrail call without modifying anything."""
def __init__(self):
super().__init__(guardrail_name="recording")
self.calls: list[tuple[str, dict]] = []
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
import copy
self.calls.append((input_type, copy.deepcopy(dict(inputs))))
return inputs
class TestResponseScanConversation:
"""Response scans receive the request conversation with the model's assistant
turns appended (inputs["structured_messages"]) plus the request's tool
definitions (inputs["tools"]), so conversation-context guardrails see the
same dialogue on both scans without caching state between hooks."""
def _response(self, *choices: Choices) -> ModelResponse:
return ModelResponse(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion",
choices=list(choices),
)
@pytest.mark.asyncio
async def test_response_scan_receives_conversation_and_tools(self):
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {
"messages": [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "what's the weather?"},
],
"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}],
}
response = self._response(
Choices(
finish_reason="tool_calls",
index=0,
message=Message(
content="Checking now.",
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id="call_1",
type="function",
function=Function(name="get_weather", arguments='{"city": "Paris"}'),
)
],
),
)
)
await handler.process_output_response(response, guardrail, request_data=request_data)
input_type, inputs = guardrail.calls[-1]
assert input_type == "response"
assert inputs["structured_messages"] == [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "what's the weather?"},
{
"role": "assistant",
"content": "Checking now.",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
}
],
},
]
assert inputs["tools"] == request_data["tools"]
@pytest.mark.asyncio
async def test_response_scan_scoping_matches_request_scan(self):
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
guardrail.skip_system_message_in_guardrail = True
guardrail.skip_tool_message_in_guardrail = True
request_data = {
"messages": [
{"role": "system", "content": "SYSTEM-PROMPT"},
{"role": "user", "content": "run the tool"},
{"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"},
{"role": "user", "content": "summarize"},
]
}
await handler.process_input_messages(data=dict(request_data), guardrail_to_apply=guardrail)
response = self._response(
Choices(finish_reason="stop", index=0, message=Message(content="Done.", role="assistant"))
)
await handler.process_output_response(response, guardrail, request_data=dict(request_data))
(_, request_inputs), (_, response_inputs) = guardrail.calls
request_conversation = request_inputs["structured_messages"]
response_conversation = response_inputs["structured_messages"]
assert response_conversation[: len(request_conversation)] == request_conversation
assert response_conversation[len(request_conversation) :] == [{"role": "assistant", "content": "Done."}]
assert all(m["role"] not in ("system", "tool") for m in response_conversation)
@pytest.mark.asyncio
async def test_scan_only_tool_results_scopes_response_conversation_and_tools(self):
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
guardrail.scan_only_tool_results = True
request_data = {
"messages": [
{"role": "user", "content": "fetch the page"},
{"role": "tool", "tool_call_id": "call_1", "content": "page content"},
],
"tools": [{"type": "function", "function": {"name": "fetch", "parameters": {}}}],
}
response = self._response(
Choices(finish_reason="stop", index=0, message=Message(content="Summary.", role="assistant"))
)
await handler.process_output_response(response, guardrail, request_data=request_data)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "tool", "tool_call_id": "call_1", "content": "page content"},
{"role": "assistant", "content": "Summary."},
]
assert "tools" not in inputs
@pytest.mark.asyncio
async def test_sdk_path_without_request_data_sends_no_conversation(self):
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
response = self._response(
Choices(finish_reason="stop", index=0, message=Message(content="Hello.", role="assistant"))
)
await handler.process_output_response(response, guardrail, request_data=None)
_, inputs = guardrail.calls[-1]
assert "structured_messages" not in inputs
assert "tools" not in inputs
assert inputs["texts"] == ["Hello."]
@pytest.mark.asyncio
async def test_multi_choice_response_appends_one_turn_per_choice(self):
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {"messages": [{"role": "user", "content": "pick one"}]}
response = self._response(
Choices(finish_reason="stop", index=0, message=Message(content="candidate one", role="assistant")),
Choices(finish_reason="stop", index=1, message=Message(content="candidate two", role="assistant")),
)
await handler.process_output_response(response, guardrail, request_data=request_data)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "user", "content": "pick one"},
{"role": "assistant", "content": "candidate one"},
{"role": "assistant", "content": "candidate two"},
]
@pytest.mark.asyncio
async def test_end_of_stream_scan_receives_conversation(self):
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {"messages": [{"role": "user", "content": "say hi"}]}
chunks = [
ModelResponseStream(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=0, finish_reason=None, delta=Delta(content="hi ", role="assistant"))
],
),
ModelResponseStream(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion.chunk",
choices=[StreamingChoices(index=0, finish_reason="stop", delta=Delta(content="there"))],
),
]
await handler.process_output_streaming_response(
responses_so_far=chunks,
guardrail_to_apply=guardrail,
request_data=request_data,
)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "user", "content": "say hi"},
{"role": "assistant", "content": "hi there"},
]
@pytest.mark.asyncio
async def test_midstream_scan_receives_the_partial_assistant_turn(self):
"""Before the stream ends there is no assembled response to fall back on,
so the in-flight scan must still see the conversation plus what the model
has produced so far."""
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {"messages": [{"role": "user", "content": "say hi"}]}
chunks = [
ModelResponseStream(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=0, finish_reason=None, delta=Delta(content="hi ", role="assistant"))
],
),
ModelResponseStream(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion.chunk",
choices=[StreamingChoices(index=0, finish_reason=None, delta=Delta(content="the"))],
),
]
await handler.process_output_streaming_response(
responses_so_far=chunks,
guardrail_to_apply=guardrail,
request_data=request_data,
)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "user", "content": "say hi"},
{"role": "assistant", "content": "hi the"},
]
@pytest.mark.asyncio
async def test_streaming_transform_scan_receives_conversation_per_choice(self):
from litellm.llms.base_llm.guardrail_translation.base_translation import StreamTransformSink
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {"messages": [{"role": "user", "content": "pick one"}]}
chunks = [
ModelResponseStream(
id="chatcmpl-1",
created=1234567890,
model="gpt-4",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=1, finish_reason=None, delta=Delta(content="two", role="assistant")),
StreamingChoices(index=0, finish_reason=None, delta=Delta(content="one", role="assistant")),
],
),
]
await handler.process_output_streaming_response(
responses_so_far=chunks,
guardrail_to_apply=guardrail,
request_data=request_data,
stream_transform_sink=StreamTransformSink(),
)
_, inputs = guardrail.calls[-1]
assert inputs["structured_messages"] == [
{"role": "user", "content": "pick one"},
{"role": "assistant", "content": "one"},
{"role": "assistant", "content": "two"},
]
@pytest.mark.asyncio
async def test_empty_response_turns_yield_no_conversation(self):
"""A conversation without a response turn would shadow the tool_calls/texts
fallback in conversation-preferring guardrails, so none is built."""
handler = OpenAIChatCompletionsHandler()
guardrail = RecordingGuardrail()
request_data = {"messages": [{"role": "user", "content": "hi"}]}
assert handler.response_scan_conversation(request_data, guardrail, []) is None

View file

@ -1229,3 +1229,164 @@ class TestOpenAIResponsesHandlerToolInjection:
names = [t.get("name") for t in result["tools"]]
assert "get_weather" in names
assert "injected_tool" in names
class MockInputsRecordingGuardrail(CustomGuardrail):
"""Records the inputs of every apply_guardrail call without modifying anything."""
def __init__(self):
super().__init__(guardrail_name="inputs-recording")
self.calls: list[tuple[str, dict]] = []
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
import copy
self.calls.append((input_type, copy.deepcopy(dict(inputs))))
return inputs
class TestResponsesResponseScanConversation:
"""Response scans receive the request conversation (input plus instructions,
translated to chat shape) with the model's assistant turn appended, and the
request's tools translated to chat-completion shape."""
def _response(self, output: list) -> ResponsesAPIResponse:
return ResponsesAPIResponse(
id="resp_1",
created_at=1234567890,
model="gpt-4",
object="response",
status="completed",
output=output,
)
@pytest.mark.asyncio
async def test_response_scan_receives_conversation_and_tools(self):
handler = OpenAIResponsesHandler()
guardrail = MockInputsRecordingGuardrail()
request_data = {
"model": "gpt-4",
"instructions": "be helpful",
"input": [{"role": "user", "content": "what's the weather?"}],
"tools": [
{
"type": "function",
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
}
],
}
response = self._response(
[
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Sunny today."}],
}
]
)
await handler.process_output_response(response, guardrail, request_data=request_data)
input_type, inputs = guardrail.calls[-1]
assert input_type == "response"
conversation = inputs["structured_messages"]
expected_request_conversation = handler.get_structured_messages(request_data)
assert conversation[:-1] == expected_request_conversation
assert conversation[0]["role"] == "system"
assert "be helpful" in str(conversation[0]["content"])
assert conversation[-1] == {"role": "assistant", "content": "Sunny today."}
assert inputs["tools"][0]["function"]["name"] == "get_weather"
@pytest.mark.asyncio
async def test_response_scan_appends_tool_call_turn(self):
handler = OpenAIResponsesHandler()
guardrail = MockInputsRecordingGuardrail()
request_data = {
"model": "gpt-4",
"input": [{"role": "user", "content": "look it up"}],
}
response = self._response(
[
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city": "Paris"}',
"status": "completed",
}
]
)
await handler.process_output_response(response, guardrail, request_data=request_data)
_, inputs = guardrail.calls[-1]
assistant_turn = inputs["structured_messages"][-1]
assert assistant_turn["role"] == "assistant"
assert assistant_turn["tool_calls"][0]["function"]["name"] == "get_weather"
@pytest.mark.asyncio
async def test_response_scan_without_request_data_sends_no_conversation(self):
handler = OpenAIResponsesHandler()
guardrail = MockInputsRecordingGuardrail()
response = self._response(
[
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello."}],
}
]
)
await handler.process_output_response(response, guardrail, request_data=None)
_, inputs = guardrail.calls[-1]
assert "structured_messages" not in inputs
assert inputs["texts"] == ["Hello."]
@pytest.mark.asyncio
async def test_streaming_completed_event_receives_conversation(self):
handler = OpenAIResponsesHandler()
guardrail = MockInputsRecordingGuardrail()
request_data = {
"model": "gpt-4",
"input": [{"role": "user", "content": "say hi"}],
}
final_chunk = {
"type": "response.completed",
"response": {
"model": "gpt-4",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi there"}],
}
],
},
}
await handler.process_output_streaming_response(
responses_so_far=[final_chunk],
guardrail_to_apply=guardrail,
request_data=request_data,
)
_, inputs = guardrail.calls[-1]
conversation = inputs["structured_messages"]
assert conversation[-1] == {"role": "assistant", "content": "hi there"}
assert conversation[:-1] == handler.get_structured_messages(request_data)

View file

@ -987,3 +987,50 @@ async def test_openai_moderation_initialize_guardrail_forwards_streaming_flags()
assert guardrail.streaming_sampling_rate == 2
finally:
litellm.logging_callback_manager._reset_all_callbacks()
@pytest.mark.asyncio
async def test_openai_moderation_response_scan_moderates_output_not_user_prompt():
"""On response scans the conversation is available in structured_messages,
but moderation must still target the model output carried in texts. The fake
moderation endpoint flags only the harmful model answer: moderating the
(benign) user prompt instead would let the flagged output through."""
from fastapi import HTTPException
from litellm.types.utils import GenericGuardrailAPIInputs
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
guardrail = OpenAIModerationGuardrail(
guardrail_name="test-openai-moderation",
)
async def moderate(input_text: str) -> OpenAIModerationResponse:
flagged = "harmful answer" in input_text
return OpenAIModerationResponse(
id="modr-123",
model="omni-moderation-latest",
results=[
OpenAIModerationResult(
flagged=flagged,
categories={"violence": flagged},
category_scores={"violence": 0.99 if flagged else 0.0},
category_applied_input_types={"violence": []},
)
],
)
with patch.object(guardrail, "async_make_request", side_effect=moderate):
inputs = GenericGuardrailAPIInputs(
texts=["a harmful answer"],
structured_messages=[
{"role": "user", "content": "a benign question"},
{"role": "assistant", "content": "a harmful answer"},
],
)
with pytest.raises(HTTPException):
await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="response",
)

View file

@ -0,0 +1,31 @@
"""Tests for the Akto guardrail's ingest payload construction."""
from litellm.proxy.guardrails.guardrail_hooks.akto.akto import AktoGuardrail
class TestBuildRequestBody:
def test_request_scan_uses_structured_messages(self):
body = AktoGuardrail.build_request_body(
inputs={
"texts": ["hi"],
"structured_messages": [{"role": "user", "content": "hi"}],
},
request_data={"messages": [{"role": "user", "content": "raw"}]},
)
assert body["messages"] == [{"role": "user", "content": "hi"}]
def test_response_scan_ingests_request_messages_not_conversation(self):
"""On response scans structured_messages carries the response turns too;
the ingested request body must stay the actual request."""
body = AktoGuardrail.build_request_body(
inputs={
"texts": ["the reply"],
"structured_messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "the reply"},
],
},
request_data={"messages": [{"role": "user", "content": "hi"}]},
prefer_structured_messages=False,
)
assert body["messages"] == [{"role": "user", "content": "hi"}]

View file

@ -1088,3 +1088,37 @@ class TestHiddenlayerGuardrailV2:
config_model = HiddenlayerGuardrailV2.get_config_model()
assert config_model is not None
assert config_model.__name__ == "HiddenlayerGuardrailConfigModel"
class TestHiddenlayerResponseScanConversation:
@pytest.mark.asyncio
async def test_response_scan_ignores_structured_messages(self, monkeypatch):
"""Response scans now carry the conversation in structured_messages; the
v1 guardrail must keep scanning texts instead of str()-coercing the last
conversation turn (a tool-call-only turn has content None, and scanning
the literal string 'None' would produce junk verdicts)."""
monkeypatch.setenv("HIDDENLAYER_API_BASE", "https://my.hiddenlayer")
guardrail = HiddenlayerGuardrail(
guardrail_name="hiddenlayer", event_hook="post_call", default_on=True
)
async def scan(project_id, metadata, payload, input_type):
if payload["messages"][-1]["content"] == "None":
return {"evaluation": {"action": "BLOCK"}, "analysis": []}
return {}
inputs = GenericGuardrailAPIInputs(
structured_messages=[
{"role": "user", "content": "look this up"},
{"role": "assistant", "content": None},
]
)
with patch.object(guardrail, "_call_hiddenlayer", side_effect=scan):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="response",
)
assert result is inputs

View file

@ -815,3 +815,46 @@ class TestPromptGuardInitializer:
from litellm.types.guardrails import SupportedGuardrailIntegrations
assert SupportedGuardrailIntegrations.PROMPTGUARD.value == "promptguard"
class TestPromptGuardResponseScanIgnoresConversation:
@pytest.mark.asyncio
async def test_response_scan_sends_texts_and_redacts_them(
self, promptguard_guardrail, mock_request_data
):
"""A response scan now carries the conversation in structured_messages,
but PromptGuard must keep scanning the response texts: sending the
conversation would make the user-role redact write-back clobber the
response texts with request content."""
resp = _make_response(
{
"decision": "redact",
"event_id": "evt-007",
"confidence": 0.99,
"threat_type": "pii_detected",
"redacted_messages": [
{"role": "user", "content": "Your SSN is *********"}
],
"threats": [],
"latency_ms": 50.0,
}
)
with patch.object(
promptguard_guardrail.async_handler, "post", return_value=resp
) as mock_post:
result = await promptguard_guardrail.apply_guardrail(
inputs={
"texts": ["Your SSN is 123-45-6789"],
"structured_messages": [
{"role": "user", "content": "what's my SSN?"},
{"role": "assistant", "content": "Your SSN is 123-45-6789"},
],
},
request_data=mock_request_data,
input_type="response",
)
sent_messages = mock_post.call_args.kwargs["json"]["messages"]
assert sent_messages == [
{"role": "user", "content": "Your SSN is 123-45-6789"}
]
assert result["texts"] == ["Your SSN is *********"]

View file

@ -679,3 +679,49 @@ class TestQualifireGuardrailRegistry:
assert "qualifire" in guardrail_class_registry
assert guardrail_class_registry["qualifire"] == QualifireGuardrail
class TestQualifireResponseScanConversation:
@pytest.mark.asyncio
async def test_response_scan_keeps_request_messages_out_of_conversation(self):
"""Response scans now receive the whole conversation in structured_messages;
Qualifire must keep sending the request messages plus a separate output,
not a conversation that already embeds the model's answer."""
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
QualifireGuardrail,
)
guardrail = QualifireGuardrail(
api_key="test_key",
prompt_injections=True,
guardrail_name="test_guardrail",
)
mock_response = MagicMock()
mock_response.json.return_value = {
"score": 100,
"status": "completed",
"evaluationResults": [],
}
mock_response.raise_for_status = MagicMock()
guardrail.async_handler.post = AsyncMock(return_value=mock_response)
request_messages = [{"role": "user", "content": "what's my balance?"}]
inputs = {
"texts": ["Your balance is $5"],
"structured_messages": request_messages
+ [{"role": "assistant", "content": "Your balance is $5"}],
}
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={"messages": request_messages},
input_type="response",
)
assert result is inputs
payload = guardrail.async_handler.post.call_args[1]["json"]
assert all(m.get("role") != "assistant" for m in payload["messages"]), (
"the model's answer belongs in `output`, not in the conversation"
)
assert payload["output"] == "Your balance is $5"

View file

@ -595,6 +595,37 @@ async def test_non_streamed_response_intervention_redacts():
assert out["texts"] == ["[redacted]"]
@pytest.mark.asyncio
async def test_response_envelope_keeps_conversation_out_of_response_content():
g = _make_guardrail()
g.async_handler.post.return_value = _mock_response("NONE")
response = ModelResponse(
choices=[Choices(finish_reason="stop", index=0, message=Message(content="answer", role="assistant"))],
model="gpt-4o-mini",
)
conversation = [
{"role": "user", "content": "question"},
{"role": "assistant", "content": "answer"},
]
request_data = {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "question"}],
"response": response,
}
await g.apply_guardrail(
inputs={"texts": ["answer"], "structured_messages": conversation, "model": "gpt-4o-mini"},
request_data=request_data,
input_type="response",
logging_obj=_logging_obj(),
)
payload = _posted_payload(g)
assert payload["response"]["texts"] == ["answer"]
assert "structured_messages" not in payload["response"]
assert payload["request"]["structured_messages"] == [{"role": "user", "content": "question"}]
@pytest.mark.asyncio
async def test_guardrail_intervened_without_texts_blocks():
g = _make_guardrail()