Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
)
store_audit_logs = False # Enterprise feature, allow users to see audit logs
skip_system_message_in_guardrail: bool = False
skip_tool_message_in_guardrail: bool = False
### end of callbacks #############

email: Optional[str] = (
Expand Down
12 changes: 11 additions & 1 deletion litellm/llms/anthropic/chat/guardrail_translation/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
openai_messages_without_system,
openai_messages_without_tool,
)
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
AnthropicPassthroughLoggingHandler,
Expand Down Expand Up @@ -108,6 +110,7 @@ async def process_input_messages(
return data

skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply)
skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply)

chat_completion_compatible_request = self._translate_to_openai(data)

Expand All @@ -117,6 +120,8 @@ async def process_input_messages(
)
if skip_system:
structured_messages = openai_messages_without_system(structured_messages)
if skip_tool:
structured_messages = openai_messages_without_tool(structured_messages)

texts_to_check: List[str] = []
images_to_check: List[str] = []
Expand All @@ -134,6 +139,7 @@ async def process_input_messages(
images_to_check=images_to_check,
task_mappings=task_mappings,
skip_system_message=skip_system,
skip_tool_message=skip_tool,
)

# Step 2: Apply guardrail to all texts in batch
Expand Down Expand Up @@ -198,13 +204,17 @@ def _extract_input_text_and_images(
images_to_check: List[str],
task_mappings: List[Tuple[int, Optional[int]]],
skip_system_message: bool = False,
skip_tool_message: bool = False,
) -> None:
"""
Extract text content and images from a message.

Override this method to customize text/image extraction logic.
"""
if skip_system_message and str(message.get("role") or "").lower() == "system":
role = str(message.get("role") or "").lower()
if skip_system_message and role == "system":
return
if skip_tool_message and role == "tool":
return
Comment on lines +214 to 218

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 skip_tool_message guard is dead code for Anthropic native tool results

In the Anthropic Messages passthrough flow, data["messages"] is in Anthropic native format. Tool results are represented as user-role messages containing type: "tool_result" content blocks — they never carry role: "tool". The guard if skip_tool_message and role == "tool": return therefore never fires for these messages, making the skip ineffective at the extraction level.

The effective behavior happens to be correct today because _extract_input_text_and_images only pulls content_item.get("text"), which returns None for tool_result blocks (they use content, not text). The structured_messages filter also works correctly because it operates on the already-translated OpenAI-format messages. However, if text extraction is ever enhanced to handle nested tool_result content blocks, this guard will silently fail to suppress them.


content = message.get("content", None)
Expand Down
15 changes: 15 additions & 0 deletions litellm/llms/base_llm/guardrail_translation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@ def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool
return bool(getattr(litellm, "skip_system_message_in_guardrail", False))


def effective_skip_tool_message_for_guardrail(guardrail_to_apply: Any) -> bool:
per = getattr(guardrail_to_apply, "skip_tool_message_in_guardrail", None)
if per is not None:
return bool(per)
import litellm

return bool(getattr(litellm, "skip_tool_message_in_guardrail", False))


def openai_messages_without_system(
messages: List[AllMessageValues],
) -> List[AllMessageValues]:
return [m for m in messages if str((m or {}).get("role") or "").lower() != "system"]


def openai_messages_without_tool(
messages: List[AllMessageValues],
) -> List[AllMessageValues]:
return [m for m in messages if str((m or {}).get("role") or "").lower() != "tool"]
24 changes: 18 additions & 6 deletions litellm/llms/openai/chat/guardrail_translation/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
openai_messages_without_system,
openai_messages_without_tool,
)
from litellm.main import stream_chunk_builder
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
Expand Down Expand Up @@ -73,6 +75,7 @@ async def process_input_messages(
return data

skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply)
skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply)

texts_to_check: List[str] = []
images_to_check: List[str] = []
Expand All @@ -91,6 +94,7 @@ async def process_input_messages(
text_task_mappings=text_task_mappings,
tool_call_task_mappings=tool_call_task_mappings,
skip_system_message=skip_system,
skip_tool_message=skip_tool,
)

# Step 2: Apply guardrail to all texts and tool calls in batch
Expand All @@ -102,11 +106,15 @@ async def process_input_messages(
inputs["tool_calls"] = tool_calls_to_check # type: ignore
structured_messages = self.get_structured_messages(data)
if structured_messages:
inputs["structured_messages"] = (
openai_messages_without_system(structured_messages)
if skip_system
else structured_messages
)
if skip_system:
structured_messages = openai_messages_without_system(
structured_messages
)
if skip_tool:
structured_messages = openai_messages_without_tool(
structured_messages
)
inputs["structured_messages"] = structured_messages
# Pass tools (function definitions) to the guardrail
tools = data.get("tools")
if tools:
Expand Down Expand Up @@ -176,13 +184,17 @@ def _extract_inputs(
text_task_mappings: List[Tuple[int, Optional[int]]],
tool_call_task_mappings: List[Tuple[int, int]],
skip_system_message: bool = False,
skip_tool_message: bool = False,
) -> None:
"""
Extract text content, images, and tool calls from a message.

Override this method to customize text/image/tool call extraction logic.
"""
if skip_system_message and str(message.get("role") or "").lower() == "system":
role = str(message.get("role") or "").lower()
if skip_system_message and role == "system":
return
if skip_tool_message and role == "tool":
return

content = message.get("content", None)
Expand Down
5 changes: 5 additions & 0 deletions litellm/proxy/guardrails/guardrail_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,11 @@ def initialize_guardrail(
"skip_system_message_in_guardrail",
getattr(litellm_params, "skip_system_message_in_guardrail", None),
)
setattr(
custom_guardrail_callback,
"skip_tool_message_in_guardrail",
getattr(litellm_params, "skip_tool_message_in_guardrail", None),
)

parsed_guardrail = Guardrail(
guardrail_id=guardrail.get("guardrail_id"),
Expand Down
10 changes: 10 additions & 0 deletions litellm/types/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,16 @@ class BaseLitellmParams(
),
)

skip_tool_message_in_guardrail: Optional[bool] = Field(
default=None,
description=(
"When True, unified guardrails skip tool-role messages when building "
"evaluation inputs (texts and structured_messages). When False, tool "
"messages are included even if litellm_settings sets a global skip. When "
"None, use the global litellm.skip_tool_message_in_guardrail setting."
),
)

# Lakera specific params
category_thresholds: Optional[LakeraCategoryThresholds] = Field(
default=None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
openai_messages_without_system,
openai_messages_without_tool,
)
from litellm.llms.openai.chat.guardrail_translation.handler import (
OpenAIChatCompletionsHandler,
Expand Down Expand Up @@ -180,6 +182,136 @@ async def apply_guardrail(
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No tests for Anthropic handler's skip_tool_message behavior

The PR mirrors skip_system_message support for tool messages across both OpenAIChatCompletionsHandler and AnthropicMessagesHandler, but the test suite only covers the OpenAI path. An equivalent test using AnthropicMessagesHandler (with Anthropic-native tool-result messages in user role) would have surfaced the dead-code issue in _extract_input_text_and_images and confirmed the feature works end-to-end for Anthropic passthrough requests.

assert "system" in roles

class TestSkipToolMessageForChatCompletions:
def test_openai_messages_without_tool(self):
msgs = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
{"role": "tool", "content": "tool result", "tool_call_id": "call_1"},
]
out = openai_messages_without_tool(msgs)
assert len(out) == 2
assert all(m["role"] != "tool" for m in out)
assert msgs[2]["content"] == "tool result"

def test_effective_skip_tool_respects_per_guardrail_over_global(
self, monkeypatch
):
monkeypatch.setattr(
litellm, "skip_tool_message_in_guardrail", True, raising=False
)

class G:
skip_tool_message_in_guardrail = False

assert effective_skip_tool_message_for_guardrail(G()) is False

class G2:
skip_tool_message_in_guardrail = None

assert effective_skip_tool_message_for_guardrail(G2()) is True

@pytest.mark.asyncio
async def test_openai_handler_skips_tool_in_guardrail_inputs(self, monkeypatch):
monkeypatch.setattr(
litellm, "skip_tool_message_in_guardrail", True, raising=False
)

captured = {}

class MockGuardrail:
skip_tool_message_in_guardrail = None

async def apply_guardrail(
self, inputs, request_data, input_type, logging_obj=None
):
captured["inputs"] = inputs
return inputs

data = {
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
{
"role": "tool",
"content": "secret tool result",
"tool_call_id": "call_1",
},
],
"model": "gpt-4o",
}

handler = OpenAIChatCompletionsHandler()
await handler.process_input_messages(
data=data,
guardrail_to_apply=MockGuardrail(),
litellm_logging_obj=None,
)

assert "secret tool result" not in captured["inputs"]["texts"]
sm = captured["inputs"].get("structured_messages") or []
assert all(m.get("role") != "tool" for m in sm)
assert data["messages"][2]["content"] == "secret tool result"

@pytest.mark.asyncio
async def test_openai_handler_per_guardrail_skip_tool_false_overrides_global(
self, monkeypatch
):
monkeypatch.setattr(
litellm, "skip_tool_message_in_guardrail", True, raising=False
)

captured = {}

class MockGuardrail:
skip_tool_message_in_guardrail = False

async def apply_guardrail(
self, inputs, request_data, input_type, logging_obj=None
):
captured["inputs"] = inputs
return inputs

data = {
"messages": [
{"role": "user", "content": "u"},
{"role": "tool", "content": "tr", "tool_call_id": "call_1"},
],
}

await OpenAIChatCompletionsHandler().process_input_messages(
data=data,
guardrail_to_apply=MockGuardrail(),
litellm_logging_obj=None,
)

assert "tr" in captured["inputs"]["texts"]
roles = {
m.get("role")
for m in (captured["inputs"].get("structured_messages") or [])
}
assert "tool" in roles

class TestAsyncPreCallHook:
@pytest.mark.asyncio
async def test_uses_mcp_event_type(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUI
import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration";
import {
choiceToSkipSystemForCreate,
choiceToSkipToolForCreate,
getGuardrailProviders,
guardrail_provider_map,
guardrailLogoMap,
Expand Down Expand Up @@ -188,6 +189,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
mode: preset.mode,
default_on: preset.defaultOn,
skip_system_message_choice: "inherit",
skip_tool_message_choice: "inherit",
};
if (preset.provider === "BlockCodeExecution") {
baseValues.confidence_threshold = 0.5;
Expand Down Expand Up @@ -433,6 +435,11 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
guardrailData.litellm_params.skip_system_message_in_guardrail = skipForCreate;
}

const skipToolForCreate = choiceToSkipToolForCreate(values.skip_tool_message_choice);
if (skipToolForCreate !== undefined) {
guardrailData.litellm_params.skip_tool_message_in_guardrail = skipToolForCreate;
}

// For Presidio PII, add the entity and action configurations
if (values.provider === "PresidioPII" && selectedEntities.length > 0) {
const piiEntitiesConfig: { [key: string]: string } = {};
Expand Down Expand Up @@ -804,6 +811,18 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
</Select>
</Form.Item>

<Form.Item
name="skip_tool_message_choice"
label="Skip tool messages in guardrail"
tooltip="Unified guardrails only: omit role: tool from guardrail evaluation input (OpenAI chat + Anthropic messages). The model still receives full messages. Use global default follows litellm_settings.skip_tool_message_in_guardrail."
>
<Select>
<Select.Option value="inherit">Use global default</Select.Option>
<Select.Option value="yes">Yes — exclude from guardrail scan</Select.Option>
<Select.Option value="no">No — always include in scan</Select.Option>
</Select>
</Form.Item>

{/* Use the GuardrailProviderFields component to render provider-specific fields */}
{!isToolPermissionProvider && !shouldRenderContentFilterConfigSettings(selectedProvider) && !shouldRenderLLMJudgeFields(selectedProvider) && (
<GuardrailProviderFields
Expand Down Expand Up @@ -1155,6 +1174,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
mode: "pre_call",
default_on: false,
skip_system_message_choice: "inherit",
skip_tool_message_choice: "inherit",
}}
>
{stepConfigs.map((step, index) => {
Expand Down
Loading
Loading