fix(guardrails): keep BLOCKED verdicts when tool_calls are malformed, ignore non-list tool_calls on /v1/messages
Some checks failed
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

A Generic Guardrail API answer of action BLOCKED with a malformed tool_calls value
raised a validation error before the BLOCKED check, so fail_on_error false let the
request through. tool_calls is now parsed when it is written back, after the verdict.

The /v1/messages output write-back read any returned tool_calls with the right length
as calls, so a guardrail handing back a mapping (HiddenLayer's evaluation dict) failed
the response. It now only reads a list, as the chat handler does.

Also drops the added docstrings and the unrelated test reformatting
This commit is contained in:
Jesus Velarde 2026-11-13 17:42:21 -08:00
parent 910fe8046f
commit 596641454f
4 changed files with 56 additions and 116 deletions

View file

@ -469,8 +469,6 @@ def _write_back_tool_use_block(block: object, shape: _ToolCallShape, rewritten_i
def _redact_output_safeguard_results(response: object, unchanged_tool_use_ids: frozenset[str]) -> None:
"""The response's safeguard verdicts judged the tool inputs as the model wrote them, so only those
for unchanged ``tool_use`` blocks survive, and no explanation can quote an input the guardrail rewrote."""
fields: Final = as_str_mapping(response)
raw: Final = fields.get("safeguard_results") if fields is not None else None
if raw is None or not isinstance(response, dict):
@ -1768,14 +1766,9 @@ class AnthropicMessagesHandler(BaseTranslation):
returned_tool_calls: Sequence[object] | None,
guardrail_name: str | None,
) -> frozenset[str] | None:
"""The nth returned call belongs to the nth ``tool_use`` block, the order the calls were scanned
in. A returned list of another length cannot be placed, so the scanned calls stand, as on the
request side. Rewritten arguments that are not a JSON object cannot become a ``tool_use`` input,
so the response fails instead of carrying the arguments the guardrail meant to replace. Returns the
ids of the ``tool_use`` blocks left unchanged when any block was rewritten, else None."""
post_guardrail_tool_calls: Final = _tool_call_shapes(
returned_tool_calls
if returned_tool_calls is not None and len(returned_tool_calls) == len(pre_guardrail_tool_calls)
if isinstance(returned_tool_calls, list) and len(returned_tool_calls) == len(pre_guardrail_tool_calls)
else scanned_tool_calls
)
tool_use_blocks: Final = tuple(

View file

@ -196,9 +196,6 @@ class GuardrailToolCallFunction(TypedDict):
@with_config(ConfigDict(extra="allow"))
class GuardrailToolCall(TypedDict):
"""A rewritten tool call, one per call the guardrail received. The call's own ``id`` and
``type`` are kept by the proxy, so ``function`` is all a guardrail has to send back."""
function: ReadOnly[GuardrailToolCallFunction]
@ -207,9 +204,7 @@ _SENT_FUNCTION: Final = TypeAdapter(Mapping[str, object])
_RESPONSE_FIELDS: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
def tool_calls_from_response(value: object) -> tuple[GuardrailToolCall, ...] | None:
"""A malformed ``tool_calls`` raises, so the guardrail's ``fail_on_error`` setting decides the
request's fate instead of the unrewritten calls going through."""
def _tool_calls_from_response(value: object) -> tuple[GuardrailToolCall, ...] | None:
return None if value is None else _TOOL_CALLS.validate_python(value)
@ -223,12 +218,9 @@ def _with_returned_function(sent_call: Mapping[str, object], returned_call: Guar
def tool_calls_to_write_back(
sent_calls: Sequence[Mapping[str, object]] | None, returned_calls: Sequence[GuardrailToolCall] | None
sent_calls: Sequence[Mapping[str, object]] | None, returned: object
) -> tuple[ChatCompletionToolCallChunk, ...] | None:
"""The calls as the guardrail received them, carrying the ``function`` it handed back, so every
endpoint keeps each call's own ``id`` and ``type``. The guardrail answers one call per call received,
in the same order, and an empty list is no rewrite. Any other length cannot be matched to the calls
by position, which is a guardrail error rather than a reason to send the calls unrewritten."""
returned_calls: Final = _tool_calls_from_response(returned)
if not returned_calls:
return None
sent_count: Final = 0 if sent_calls is None else len(sent_calls)
@ -246,7 +238,7 @@ class GenericGuardrailAPIResponse:
texts: list[str] | None
images: list[str] | None
tools: list[GuardrailToolParam] | None
tool_calls: Sequence[GuardrailToolCall] | None
tool_calls: object
structured_messages: Sequence[AllMessageValues] | None
action: str
blocked_reason: str | None
@ -261,7 +253,7 @@ class GenericGuardrailAPIResponse:
tools: list[GuardrailToolParam] | None = None,
stream_holdback_chars: list[int] | None = None,
structured_messages: Sequence[AllMessageValues] | None = None,
tool_calls: Sequence[GuardrailToolCall] | None = None,
tool_calls: object = None,
) -> None:
self.action = action
self.blocked_reason = blocked_reason
@ -290,5 +282,5 @@ class GenericGuardrailAPIResponse:
tools=data.get("tools"),
stream_holdback_chars=stream_holdback_chars,
structured_messages=structured_messages_from_response(data.get("structured_messages")),
tool_calls=tool_calls_from_response(fields.get("tool_calls")),
tool_calls=fields.get("tool_calls"),
)

View file

@ -277,42 +277,12 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing:
@staticmethod
def _ended_sse_chunks() -> list:
events = [
(
"message_start",
{
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 1, "output_tokens": 0},
},
},
),
(
"content_block_start",
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
),
(
"content_block_delta",
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello "}},
),
(
"content_block_delta",
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "world"}},
),
("message_start", {"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello "}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "world"}}),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 2},
},
),
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 2}}),
("message_stop", {"type": "message_stop"}),
]
return [f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() for name, payload in events]
@ -409,58 +379,13 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing:
@staticmethod
def _ended_tool_use_sse_chunks() -> list:
events = [
(
"message_start",
{
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 1, "output_tokens": 0},
},
},
),
(
"content_block_start",
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "lookup_fruit", "input": {}},
},
),
(
"content_block_delta",
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}},
),
(
"content_block_delta",
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"fruit": "persim'},
},
),
(
"content_block_delta",
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": 'mon"}'},
},
),
("message_start", {"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_1", "name": "lookup_fruit", "input": {}}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": '{"fruit": "persim'}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": 'mon"}'}}),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
"usage": {"output_tokens": 2},
},
),
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": None}, "usage": {"output_tokens": 2}}),
("message_stop", {"type": "message_stop"}),
]
return [f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() for name, payload in events]
@ -481,8 +406,7 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing:
json.loads(line[len("data:") :].strip())["delta"]["partial_json"]
for chunk in chunks
for line in chunk.decode().split("\n")
if line.startswith("data:")
and json.loads(line[len("data:") :].strip()).get("type") == "content_block_delta"
if line.startswith("data:") and json.loads(line[len("data:") :].strip()).get("type") == "content_block_delta"
]
@pytest.mark.asyncio
@ -575,10 +499,7 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing:
logging_obj: Optional[LiteLLMLoggingObj] = None,
) -> GenericGuardrailAPIInputs:
for tool_call in inputs.get("tool_calls", []):
if (
isinstance(tool_call, ChatCompletionMessageToolCall)
and "persimmon" in tool_call.function.arguments
):
if isinstance(tool_call, ChatCompletionMessageToolCall) and "persimmon" in tool_call.function.arguments:
tool_call.function.arguments = '{"fruit": "[MASKED]"}'
return inputs
@ -2684,7 +2605,9 @@ class TestAnthropicMessagesScanOnlyToolResults:
{"role": "user", "content": "weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {"city": "Paris"}}],
"content": [
{"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {"city": "Paris"}}
],
},
{
"role": "user",
@ -3029,7 +2952,9 @@ class TestStructuredWriteBackKeepsToolResults:
],
ids=["tool_reference", "empty"],
)
async def test_tool_result_stays_right_after_its_tool_use(self, tool_result_content, expected_written_back_content):
async def test_tool_result_stays_right_after_its_tool_use(
self, tool_result_content, expected_written_back_content
):
handler = AnthropicMessagesHandler()
data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)}
@ -3699,6 +3624,18 @@ class TestOutputToolCallRewrites:
)
assert "SECRET-123" not in json.dumps(response)
@pytest.mark.asyncio
async def test_tool_calls_handed_back_as_a_mapping_leave_the_response_as_scanned(self) -> None:
response: Final = _dict_tool_use_response()
await AnthropicMessagesHandler().process_output_response(
response=cast(Any, response),
guardrail_to_apply=_tool_call_returning_guardrail(lambda tool_calls: {"verdict": "ok"}),
litellm_logging_obj=MagicMock(),
)
assert _tool_use_block(response)["input"] == {"cmd": "echo SECRET-123"}
@pytest.mark.asyncio
async def test_rewritten_arguments_that_are_not_a_json_object_fail_the_response(self) -> None:
response: Final = _dict_tool_use_response()

View file

@ -2790,6 +2790,24 @@ class TestToolCallsInResponse:
with pytest.raises(Exception, match="Generic Guardrail API failed"):
await _tool_calls_after(guardrail)
@pytest.mark.asyncio
@pytest.mark.parametrize("fail_on_error", [True, False])
@pytest.mark.parametrize(
"tool_calls",
["not a list", [{"function": {"name": "run_shell", "arguments": {"cmd": "echo [REDACTED]"}}}]],
ids=["not_a_list", "object_arguments"],
)
async def test_a_blocked_answer_blocks_even_when_its_tool_calls_are_malformed(
self, tool_calls: object, fail_on_error: bool
) -> None:
guardrail: Final = _guardrail_answering(
{"action": "BLOCKED", "blocked_reason": "dangerous command", "tool_calls": tool_calls},
fail_on_error=fail_on_error,
)
with pytest.raises(GuardrailRaisedException, match="dangerous command"):
await _tool_calls_after(guardrail)
@pytest.mark.asyncio
async def test_fail_on_error_off_sends_the_calls_as_received_when_the_answer_cannot_be_applied(self) -> None:
guardrail: Final = _guardrail_answering(