fix(guardrails/anthropic): apply compressed structured_messages on /v1/messages and fix guardrail_information logging

Two bugs fixed for /v1/messages (Anthropic native endpoint) with headroom prompt compression guardrail:

1. guardrail_information null in spend logs: function_setup stored a .copy() of
   litellm_metadata as litellm_params["metadata"]. Guardrails write to litellm_metadata
   after this point; the copy didn't reflect those mutations. Changed to use a reference
   so guardrail writes are visible at logging time.

2. Compressed structured_messages not applied: AnthropicMessagesHandler.process_input_messages
   sent OpenAI-format structured_messages to the guardrail but never read back the
   compressed result. Added identity-check (is not) to detect when guardrail returns
   different structured_messages and convert them back to Anthropic format via
   anthropic_messages_pt before sending the request.

Uses identity check (not equality) to distinguish pass-through guardrails (which echo
the original structured_messages unchanged) from compression guardrails (which return
a new list object with fewer messages).
This commit is contained in:
Krrish Dholakia 2026-06-25 22:16:06 -07:00
parent 9d32b4081b
commit 498b8d85cd
3 changed files with 640 additions and 1544 deletions

View file

@ -125,9 +125,7 @@ class AnthropicMessagesHandler(BaseTranslation):
texts_to_check: List[str] = []
images_to_check: List[str] = []
tools_to_check: List[ChatCompletionToolParam] = (
chat_completion_compatible_request.get("tools", [])
)
tools_to_check: List[ChatCompletionToolParam] = chat_completion_compatible_request.get("tools", [])
task_mappings: List[Tuple[int, Optional[int]]] = []
# Step 1: Extract all text content and images
@ -164,6 +162,8 @@ class AnthropicMessagesHandler(BaseTranslation):
guardrailed_texts = guardrailed_inputs.get("texts", [])
guardrailed_tools = guardrailed_inputs.get("tools")
guardrailed_structured_messages = guardrailed_inputs.get("structured_messages")
if guardrailed_tools is not None:
# Convert tools back from OpenAI format to Anthropic format
anthropic_config = AnthropicConfig()
@ -172,19 +172,35 @@ class AnthropicMessagesHandler(BaseTranslation):
converted_tool, mcp_server = anthropic_config._map_tool_helper(tool)
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
# Step 3: Map guardrail responses back to original message structure
await self._apply_guardrail_responses_to_input(
messages=messages,
responses=guardrailed_texts,
task_mappings=task_mappings,
)
# If guardrail returned modified structured_messages (OpenAI format), convert
# them back to Anthropic format and replace the request messages wholesale.
# Only do this when the returned messages differ from what we sent — a
# pass-through guardrail echoes the original structured_messages unchanged.
if (
guardrailed_structured_messages is not None
and guardrailed_structured_messages is not structured_messages
):
from litellm.litellm_core_utils.prompt_templates.factory import (
anthropic_messages_pt,
)
verbose_proxy_logger.debug(
"Anthropic Messages: Processed input messages: %s", messages
)
model = data.get("model", "")
data["messages"] = anthropic_messages_pt(
messages=guardrailed_structured_messages,
model=model,
llm_provider="anthropic",
)
else:
# Step 3: Map guardrail responses back to original message structure
await self._apply_guardrail_responses_to_input(
messages=messages,
responses=guardrailed_texts,
task_mappings=task_mappings,
)
verbose_proxy_logger.debug("Anthropic Messages: Processed input messages: %s", messages)
return data
@ -288,9 +304,7 @@ class AnthropicMessagesHandler(BaseTranslation):
elif isinstance(content, list) and content_idx_optional is not None:
# Replace specific text item in list content
messages[msg_idx]["content"][content_idx_optional]["text"] = (
guardrail_response
)
messages[msg_idx]["content"][content_idx_optional]["text"] = guardrail_response
async def process_output_response(
self,
@ -369,9 +383,7 @@ class AnthropicMessagesHandler(BaseTranslation):
task_mappings=task_mappings,
)
verbose_proxy_logger.debug(
"Anthropic Messages: Processed output response: %s", response
)
verbose_proxy_logger.debug("Anthropic Messages: Processed output response: %s", response)
return response
@ -391,20 +403,14 @@ class AnthropicMessagesHandler(BaseTranslation):
has_ended = self._check_streaming_has_ended(responses_so_far)
if has_ended:
# build the model response from the responses_so_far
built_response = (
AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=responses_so_far,
litellm_logging_obj=cast("LiteLLMLoggingObj", litellm_logging_obj),
model="",
)
built_response = AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=responses_so_far,
litellm_logging_obj=cast("LiteLLMLoggingObj", litellm_logging_obj),
model="",
)
# Check if model_response is valid and has choices before accessing
if (
built_response is not None
and hasattr(built_response, "choices")
and built_response.choices
):
if built_response is not None and hasattr(built_response, "choices") and built_response.choices:
model_response = cast(ModelResponse, built_response)
first_choice = cast(Choices, model_response.choices[0])
tool_calls_list = cast(
@ -418,16 +424,16 @@ class AnthropicMessagesHandler(BaseTranslation):
if tool_calls_list:
guardrail_inputs["tool_calls"] = tool_calls_list
_guardrailed_inputs = await guardrail_to_apply.apply_guardrail( # allow rejecting the response, if invalid
inputs=guardrail_inputs,
request_data=request_data if request_data is not None else {},
input_type="response",
logging_obj=litellm_logging_obj,
_guardrailed_inputs = (
await guardrail_to_apply.apply_guardrail( # allow rejecting the response, if invalid
inputs=guardrail_inputs,
request_data=request_data if request_data is not None else {},
input_type="response",
logging_obj=litellm_logging_obj,
)
)
else:
verbose_proxy_logger.debug(
"Skipping output guardrail - model response has no choices"
)
verbose_proxy_logger.debug("Skipping output guardrail - model response has no choices")
return responses_so_far
string_so_far = self.get_streaming_string_so_far(responses_so_far)
@ -454,9 +460,7 @@ class AnthropicMessagesHandler(BaseTranslation):
request_data[key] = response
if "litellm_metadata" not in request_data:
user_metadata = self.transform_user_api_key_dict_to_metadata(
user_api_key_dict
)
user_metadata = self.transform_user_api_key_dict_to_metadata(user_api_key_dict)
if user_metadata:
request_data["litellm_metadata"] = user_metadata
return request_data
@ -604,9 +608,7 @@ class AnthropicMessagesHandler(BaseTranslation):
if delta.get("type") == "text_delta":
text += delta.get("text", "")
except json.JSONDecodeError:
verbose_proxy_logger.warning(
f"Failed to parse JSON from SSE data: {data_line}"
)
verbose_proxy_logger.warning(f"Failed to parse JSON from SSE data: {data_line}")
except Exception as e:
verbose_proxy_logger.error(f"Error extracting text from SSE: {e}")
@ -670,14 +672,10 @@ class AnthropicMessagesHandler(BaseTranslation):
if stop_reason is not None:
return True
except json.JSONDecodeError:
verbose_proxy_logger.warning(
f"Failed to parse JSON from SSE data: {data_line}"
)
verbose_proxy_logger.warning(f"Failed to parse JSON from SSE data: {data_line}")
except Exception as e:
verbose_proxy_logger.error(
f"Error checking streaming end in SSE: {e}"
)
verbose_proxy_logger.error(f"Error checking streaming end in SSE: {e}")
# Handle already-parsed dict format
elif isinstance(response, dict):
@ -783,10 +781,7 @@ class AnthropicMessagesHandler(BaseTranslation):
if isinstance(content_block, dict):
if content_block.get("type") == "text":
cast(Dict[str, Any], content_block)["text"] = guardrail_response
elif (
hasattr(content_block, "type")
and getattr(content_block, "type", None) == "text"
):
elif hasattr(content_block, "type") and getattr(content_block, "type", None) == "text":
# Update Pydantic object's text attribute
if hasattr(content_block, "text"):
content_block.text = guardrail_response

File diff suppressed because it is too large Load diff

View file

@ -12,9 +12,7 @@ from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../../../../../..")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../../../../../..")) # Adds the parent directory to the system path
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.llms.anthropic.chat.guardrail_translation.handler import (
@ -51,9 +49,7 @@ class MockDynamicGuardrail(CustomGuardrail):
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
self.dynamic_params = self.get_guardrail_dynamic_request_body_params(
request_data
)
self.dynamic_params = self.get_guardrail_dynamic_request_body_params(request_data)
return inputs
@ -103,17 +99,11 @@ class TestAnthropicMessagesHandlerInputProcessing:
data = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hello"}],
"litellm_metadata": {
"guardrails": [
{"cygnal-monitor": {"extra_body": {"policy_id": "policy-123"}}}
]
},
"litellm_metadata": {"guardrails": [{"cygnal-monitor": {"extra_body": {"policy_id": "policy-123"}}}]},
}
with patch("litellm.proxy.proxy_server.premium_user", True):
await handler.process_input_messages(
data=data, guardrail_to_apply=guardrail
)
await handler.process_input_messages(data=data, guardrail_to_apply=guardrail)
assert data.get("litellm_metadata", {}).get("guardrails")
assert guardrail.dynamic_params == {"policy_id": "policy-123"}
@ -216,9 +206,7 @@ class TestAnthropicMessagesHandlerInputProcessing:
# Mock _check_streaming_has_ended to return False (stream not ended)
with (
patch.object(handler, "_check_streaming_has_ended", return_value=False),
patch.object(
handler, "get_streaming_string_so_far", return_value="partial text"
),
patch.object(handler, "get_streaming_string_so_far", return_value="partial text"),
):
responses_so_far = [b"data: some chunk"]
@ -249,9 +237,7 @@ class TestAnthropicMessagesHandlerInputProcessing:
data = {
"model": "claude-opus-4-6",
"messages": [
{"role": "user", "content": "What is the weather in San Francisco?"}
],
"messages": [{"role": "user", "content": "What is the weather in San Francisco?"}],
"tools": [
{
"type": "tool_search_tool_regex_20251119",
@ -295,6 +281,122 @@ class TestAnthropicMessagesHandlerInputProcessing:
assert "input_schema" in tools[1]
class MockStructuredMessagesGuardrail(CustomGuardrail):
"""Mock guardrail that returns compressed structured_messages."""
def __init__(self, guardrail_name: str, compressed_messages: List[Any]):
super().__init__(guardrail_name=guardrail_name)
self.compressed_messages = compressed_messages
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
result = dict(inputs)
result["structured_messages"] = self.compressed_messages
return GenericGuardrailAPIInputs(**result)
class TestAnthropicStructuredMessagesApplied:
"""Test that structured_messages from guardrail response are applied back in Anthropic format."""
@pytest.mark.asyncio
async def test_structured_messages_replaced_in_anthropic_format(self):
"""When guardrail returns structured_messages (OpenAI format), they must be
converted back to Anthropic format and written to data['messages']."""
handler = AnthropicMessagesHandler()
compressed_openai_messages = [
{"role": "user", "content": "compressed content"},
]
guardrail = MockStructuredMessagesGuardrail(
guardrail_name="test-compressor",
compressed_messages=compressed_openai_messages,
)
data: dict = {
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "original long content that gets compressed"},
],
}
result = await handler.process_input_messages(
data=data, guardrail_to_apply=guardrail, litellm_logging_obj=MagicMock()
)
messages = result["messages"]
assert len(messages) == 1
assert messages[0]["role"] == "user"
content = messages[0]["content"]
if isinstance(content, str):
assert content == "compressed content"
elif isinstance(content, list):
assert any(block.get("text") == "compressed content" for block in content if isinstance(block, dict))
@pytest.mark.asyncio
async def test_no_structured_messages_falls_back_to_text_patching(self):
"""When guardrail returns no structured_messages, the original text-patch path runs."""
handler = AnthropicMessagesHandler()
guardrail = MockPassThroughGuardrail(guardrail_name="test")
original_content = "original content"
data: dict = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": original_content}],
}
result = await handler.process_input_messages(
data=data, guardrail_to_apply=guardrail, litellm_logging_obj=MagicMock()
)
messages = result["messages"]
assert len(messages) == 1
content = messages[0]["content"]
if isinstance(content, str):
assert content == original_content
class TestFunctionSetupLitellmMetadataReference:
"""Regression test: guardrail info written to litellm_metadata must appear in
standard logging payload for endpoints that use litellm_metadata (e.g. /v1/messages)."""
def test_litellm_metadata_reference_not_copy(self):
"""litellm_params['metadata'] must be the same object as kwargs['litellm_metadata']
so mutations by guardrails after function_setup are visible at logging time."""
import litellm
from datetime import datetime
litellm_metadata_dict: dict = {"user_api_key": "test-key"}
kwargs = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hello"}],
"litellm_call_id": "test-call-id",
"litellm_metadata": litellm_metadata_dict,
}
logging_obj, _ = litellm.utils.function_setup(
original_function="anthropic_messages",
rules_obj=litellm.utils.Rules(),
start_time=datetime.now(),
**kwargs,
)
litellm_metadata_dict["standard_logging_guardrail_information"] = [
{"guardrail_name": "test-guardrail", "guardrail_status": "success"}
]
metadata_in_logging = logging_obj.litellm_params.get("metadata", {})
assert "standard_logging_guardrail_information" in metadata_in_logging, (
"guardrail info written to litellm_metadata after function_setup must be "
"visible in litellm_params['metadata'] — check function_setup uses a "
"reference not a copy for litellm_metadata endpoints"
)
if __name__ == "__main__":
# Run the tests
pytest.main([__file__, "-v"])