mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge d338fb4fb4 into 1df25e26cf
This commit is contained in:
commit
e1ccbd6488
19 changed files with 1280 additions and 39 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 *********"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue