Skip to content
Merged
10 changes: 5 additions & 5 deletions basedpyright-code-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"limit": 29204
},
"reportArgumentType": {
"limit": 2635
"limit": 2634
},
"reportAssignmentType": {
"limit": 329
Expand All @@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
"limit": 9227
"limit": 9226
},
"reportFunctionMemberAccess": {
"limit": 7
Expand Down Expand Up @@ -105,7 +105,7 @@
"limit": 113
},
"reportUnknownMemberType": {
"limit": 40340
"limit": 40339
},
"reportUnknownParameterType": {
"limit": 20293
Expand All @@ -117,13 +117,13 @@
"limit": 122
},
"reportUnnecessaryComparison": {
"limit": 703
"limit": 702
},
"reportUnnecessaryContains": {
"limit": 5
},
"reportUnnecessaryIsInstance": {
"limit": 865
"limit": 864
},
"reportUntypedBaseClass": {
"limit": 72
Expand Down
10 changes: 10 additions & 0 deletions litellm/integrations/custom_guardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,16 @@ async def async_post_call_success_deployment_hook(

return result

def supports_scan_only_tool_results(self) -> bool:
"""Whether this guardrail can scan tool-result content.

Guardrails whose own role filtering only ever scans human-authored
messages override this to return False, so configuring them with
``scan_only_tool_results`` is rejected at initialization instead of
silently scanning nothing on every request.
"""
return True

def should_run_guardrail(
self,
data,
Expand Down
69 changes: 43 additions & 26 deletions litellm/llms/anthropic/chat/guardrail_translation/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@
)
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
anthropic_tool_name,
effective_scan_only_tool_results_for_guardrail,
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
openai_messages_without_system,
openai_messages_without_tool,
merge_guardrailed_scoped_messages,
merge_returned_tools_into_request_tools,
scoped_structured_message_indices,
)
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
AnthropicPassthroughLoggingHandler,
Expand Down Expand Up @@ -326,19 +329,25 @@ async def process_input_messages(

skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply)
skip_tool: Final = effective_skip_tool_message_for_guardrail(guardrail_to_apply)
scan_only_tool_results: Final = effective_scan_only_tool_results_for_guardrail(guardrail_to_apply)

chat_completion_compatible_request: Final = self._translate_to_openai(data)

structured_messages = cast(
full_structured_messages: Final = cast(
list[AllMessageValues],
chat_completion_compatible_request.get("messages", []),
)
if skip_system:
structured_messages = openai_messages_without_system(structured_messages)
if skip_tool:
structured_messages = openai_messages_without_tool(structured_messages)
scoped_message_indices: Final = scoped_structured_message_indices(
full_structured_messages,
scan_only_tool_results=scan_only_tool_results,
skip_system=skip_system,
skip_tool=skip_tool,
)
structured_messages: Final = [full_structured_messages[index] for index in scoped_message_indices]

tools_to_check: Final[list[ChatCompletionToolParam]] = chat_completion_compatible_request.get("tools", [])
tools_to_check: Final[list[ChatCompletionToolParam]] = (
[] if scan_only_tool_results else chat_completion_compatible_request.get("tools", [])
)

# Step 1: Extract all text content and images
extracted: Final = tuple(
Expand All @@ -347,6 +356,7 @@ async def process_input_messages(
msg_idx=msg_idx,
skip_system_message=skip_system,
skip_tool_message=skip_tool,
scan_only_tool_results=scan_only_tool_results,
)
for msg_idx, message in enumerate(messages)
)
Expand Down Expand Up @@ -388,14 +398,29 @@ async def process_input_messages(
if converted_tool is not None:
anthropic_tools.append(converted_tool)
# Note: MCP servers are handled separately in the main transformation
data["tools"] = anthropic_tools
data["tools"] = (
merge_returned_tools_into_request_tools(
request_tools=data.get("tools"),
returned_tools=anthropic_tools,
tool_name=anthropic_tool_name,
)
if scan_only_tool_results
else anthropic_tools
)

guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages")
if (
guardrailed_structured_messages is not None
and guardrailed_structured_messages is not original_structured_messages
):
self._write_back_structured_messages(data, guardrailed_structured_messages)
self._write_back_structured_messages(
data,
merge_guardrailed_scoped_messages(
full_messages=full_structured_messages,
scoped_indices=scoped_message_indices,
guardrailed_scoped=guardrailed_structured_messages,
),
)
else:
# Step 3: Map guardrail responses back to original message structure
await self._apply_guardrail_responses_to_input(
Expand Down Expand Up @@ -461,6 +486,7 @@ def _extract_input_text_and_images(
msg_idx: int,
skip_system_message: bool = False,
skip_tool_message: bool = False,
scan_only_tool_results: bool = False,
) -> ExtractedInput:
"""
Extract text content and images from a message.
Expand All @@ -471,6 +497,8 @@ def _extract_input_text_and_images(

content: Final = message.get("content", None)
if isinstance(content, str):
if scan_only_tool_results:
return EMPTY_EXTRACTED_INPUT
return ExtractedInput(scanned=(ScannedText(content, MessageContentTarget(msg_idx)),), images=())
if not isinstance(content, list):
return EMPTY_EXTRACTED_INPUT
Expand All @@ -481,6 +509,7 @@ def _extract_input_text_and_images(
msg_idx=msg_idx,
content_idx=content_idx,
skip_tool_message=skip_tool_message,
scan_only_tool_results=scan_only_tool_results,
)
for content_idx, content_item in enumerate(content)
if isinstance(content_item, dict)
Expand All @@ -497,12 +526,16 @@ def _extract_content_block(
msg_idx: int,
content_idx: int,
skip_tool_message: bool,
scan_only_tool_results: bool = False,
) -> ExtractedInput:
if content_item.get("type") == "tool_result":
if skip_tool_message:
return EMPTY_EXTRACTED_INPUT
return cls._extract_tool_result(content_item=content_item, msg_idx=msg_idx, content_idx=content_idx)

if scan_only_tool_results:
return EMPTY_EXTRACTED_INPUT

text_str: Final = content_item.get("text", None)
return ExtractedInput(
scanned=(
Expand Down Expand Up @@ -551,22 +584,6 @@ def _image_sources(block: Mapping[str, Any]) -> tuple[str, ...]:
data: Final = source.get("data")
return (data,) if data else ()

def _extract_input_tools(
self,
tools: list[dict[str, Any]],
tools_to_check: list[ChatCompletionToolParam],
) -> None:
"""
Extract tools from a message.
"""
## CHECK FOR TOOLS
if tools is not None and isinstance(tools, list):
# TRANSFORM ANTHROPIC TOOLS TO OPENAI TOOLS
openai_tools: Final = self.adapter.translate_anthropic_tools_to_openai(
tools=cast(list[AllAnthropicToolsValues], tools)
)
tools_to_check.extend(openai_tools)

async def _apply_guardrail_responses_to_input(
self,
messages: list[dict[str, Any]],
Expand Down
126 changes: 120 additions & 6 deletions litellm/llms/base_llm/guardrail_translation/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import json
from typing import Any, Final
from collections.abc import Callable, Iterator, Sequence
from typing import Any, Final, TypeVar

from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
from litellm.types.llms.openai import AllMessageValues
Expand Down Expand Up @@ -113,13 +114,126 @@ def effective_skip_tool_message_for_guardrail(guardrail_to_apply: Any) -> bool:
return bool(getattr(litellm, "skip_tool_message_in_guardrail", False))


def _message_role(message: AllMessageValues) -> str:
return str((message or {}).get("role") or "").lower()


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"]
messages: Sequence[AllMessageValues],
) -> tuple[AllMessageValues, ...]:
return tuple(m for m in messages if _message_role(m) != "system")


def openai_messages_without_tool(
messages: list[AllMessageValues],
messages: Sequence[AllMessageValues],
) -> tuple[AllMessageValues, ...]:
return tuple(m for m in messages if _message_role(m) != "tool")


def effective_scan_only_tool_results_for_guardrail(guardrail_to_apply: object) -> bool:
return getattr(guardrail_to_apply, "scan_only_tool_results", None) is True


def role_out_of_guardrail_scope(
role: str,
*,
skip_system_message: bool,
skip_tool_message: bool,
scan_only_tool_results: bool = False,
) -> bool:
if skip_system_message and role == "system":
return True
if skip_tool_message and role == "tool":
return True
return scan_only_tool_results and role != "tool"
Comment thread
veria-ai[bot] marked this conversation as resolved.
Outdated


def scoped_structured_message_indices(
messages: Sequence[AllMessageValues],
*,
scan_only_tool_results: bool,
skip_system: bool,
skip_tool: bool,
) -> tuple[int, ...]:
return tuple(
index
for index, message in enumerate(messages)
if not role_out_of_guardrail_scope(
_message_role(message),
skip_system_message=skip_system,
skip_tool_message=skip_tool,
scan_only_tool_results=scan_only_tool_results,
)
)


ToolT = TypeVar("ToolT")


def openai_tool_name(tool: object) -> str | None:
if not isinstance(tool, dict):
return None
function: Final = tool.get("function")
if isinstance(function, dict):
function_name: Final = function.get("name")
return function_name if isinstance(function_name, str) else None
flat_name: Final = tool.get("name")
return flat_name if isinstance(flat_name, str) else None


def anthropic_tool_name(tool: object) -> str | None:
name: Final = tool.get("name") if isinstance(tool, dict) else None
return name if isinstance(name, str) else None


def merge_returned_tools_into_request_tools(
request_tools: Sequence[ToolT] | None,
returned_tools: Sequence[ToolT],
tool_name: Callable[[ToolT], str | None],
) -> list[ToolT]:
"""Union of the request's tools and guardrail-returned tools, keyed by name.

Under ``scan_only_tool_results`` the guardrail never saw the request's
tools, so a returned list can neither replace them (it would drop every
user-defined function) nor be discarded (it may carry a tool the guardrail
synthesized and told the model to call, like Compresr's retrieve tool).
Keep every request tool and append only returned tools whose names aren't
already taken.
"""
originals: Final = tuple(request_tools or ())
taken_names: Final = frozenset(name for tool in originals if (name := tool_name(tool)) is not None)
additions: Final = tuple(tool for tool in returned_tools if tool_name(tool) not in taken_names)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
return [*originals, *additions]


def merge_guardrailed_scoped_messages(
full_messages: Sequence[AllMessageValues],
scoped_indices: Sequence[int],
guardrailed_scoped: Sequence[AllMessageValues],
) -> list[AllMessageValues]:
return [m for m in messages if str((m or {}).get("role") or "").lower() != "tool"]
"""Substitute guardrail-returned messages back into the full conversation.

Guardrails only ever see the scoped subset of messages, so a replacement
list they hand back describes that subset, not the whole request. Writing
it over ``data["messages"]`` wholesale would silently drop every
out-of-scope message (system prompt, prior turns). Instead, swap each
returned message into the position its scoped original came from; extra
returned messages land after the last scoped position, and scoped
originals without a counterpart are treated as removed by the guardrail.
When nothing was filtered out this degenerates to the returned list
itself, preserving wholesale-replacement behavior for unscoped guardrails.
"""
replacements: Final = dict(zip(scoped_indices, guardrailed_scoped))
removed: Final = frozenset(scoped_indices[len(guardrailed_scoped) :])
appended: Final = tuple(guardrailed_scoped[len(scoped_indices) :])
last_scoped_index: Final = scoped_indices[-1] if scoped_indices else None

def _merged() -> Iterator[AllMessageValues]:
for index, message in enumerate(full_messages):
if index in removed:
continue
yield replacements.get(index, message)
if index == last_scoped_index:
yield from appended

return list(_merged())
Loading
Loading