mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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
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:
parent
910fe8046f
commit
596641454f
4 changed files with 56 additions and 116 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue