Repository navigation
fix(generic_guardrail_api): return tool_calls on the guardrail response #41013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
0b19b55
f0f06ae
47cf317
f4d92fb
e5cab21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1380,6 +1380,35 @@ def test_from_dict_malformed_holdback_degrades_to_zero(self): | |
|
|
||
| assert response.stream_holdback_chars == [3, 0, 0, 0] | ||
|
|
||
| def test_from_dict_parses_tool_calls(self): | ||
| """``tool_calls`` goes out on the request, so the response has to carry it back.""" | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| tool_calls = [ | ||
| { | ||
| "id": "call_1", | ||
| "type": "function", | ||
| "function": {"name": "send_email", "arguments": '{"to": "jane@example.com"}'}, | ||
| } | ||
| ] | ||
|
|
||
| response = GenericGuardrailAPIResponse.from_dict( | ||
| {"action": "GUARDRAIL_INTERVENED", "texts": ["Done"], "tool_calls": tool_calls} | ||
| ) | ||
|
|
||
| assert response.tool_calls == tool_calls | ||
|
|
||
| def test_from_dict_tool_calls_absent_is_none(self): | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| response = GenericGuardrailAPIResponse.from_dict({"action": "NONE", "texts": ["hi"]}) | ||
|
|
||
| assert response.tool_calls is None | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_apply_guardrail_flows_holdback_back_to_inputs(self, generic_guardrail): | ||
| """A GUARDRAIL_INTERVENED response with stream_holdback_chars is surfaced on | ||
|
|
@@ -1876,6 +1905,141 @@ async def test_builtin_tools_without_function_block_do_not_crash( | |
|
|
||
| assert forwarded_tools == tools | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_guardrail_returned_tool_calls_flow_back_to_inputs(self, generic_guardrail): | ||
| """A guardrail that rewrites tool arguments has to be able to return them. | ||
|
|
||
| ``tool_calls`` is sent on the request, so the matching response field is what closes | ||
| the loop. Without it the calls were dropped on the floor and the caller silently | ||
| kept the values it sent in -- for a rewriting guardrail (reversible redaction, for | ||
| instance) the rewritten arguments never reached the client. | ||
| """ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These docstrings restate straightforward behavior, including similar blocks near lines 1937 and 1999. Repository policy requires removing them before merge Context Used: CLAUDE.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the docstrings from the new tests in e5cab21, and from the new source helpers too, per the comment rule in AGENTS.md |
||
| guardrailed_tool_calls = [ | ||
| { | ||
| "id": "call_1", | ||
| "type": "function", | ||
| "function": {"name": "send_email", "arguments": '{"to": "jane@example.com"}'}, | ||
| } | ||
| ] | ||
| mock_response = MagicMock() | ||
| mock_response.json.return_value = { | ||
| "action": "GUARDRAIL_INTERVENED", | ||
| "texts": ["Done"], | ||
| "tool_calls": guardrailed_tool_calls, | ||
| } | ||
| mock_response.raise_for_status = MagicMock() | ||
|
|
||
| with patch.object(generic_guardrail.async_handler, "post", return_value=mock_response): | ||
| result = await generic_guardrail.apply_guardrail( | ||
| inputs={"texts": ["Done"]}, | ||
| request_data={}, | ||
| input_type="response", | ||
| ) | ||
|
|
||
| assert result["tool_calls"] == guardrailed_tool_calls | ||
|
|
||
| def test_guardrail_tool_calls_take_precedence_over_the_sent_ones(self, generic_guardrail): | ||
| """The returned calls win over the ones that went out. | ||
|
|
||
| Built through ``_build_guardrail_return_inputs`` rather than ``apply_guardrail`` so | ||
| the assertion is about this mapping, not about request-model coercion of the calls | ||
| the caller supplied. | ||
| """ | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| sent = [{"id": "call_1", "type": "function", "function": {"name": "send_email", "arguments": "{}"}}] | ||
| returned = [ | ||
| { | ||
| "id": "call_1", | ||
| "type": "function", | ||
| "function": {"name": "send_email", "arguments": '{"to": "jane@example.com"}'}, | ||
| } | ||
| ] | ||
|
|
||
| result = generic_guardrail._build_guardrail_return_inputs( | ||
| texts=["Done"], | ||
| images=None, | ||
| tools=None, | ||
| tool_calls=sent, | ||
| guardrail_response=GenericGuardrailAPIResponse.from_dict( | ||
| {"action": "GUARDRAIL_INTERVENED", "texts": ["Done"], "tool_calls": returned} | ||
| ), | ||
| ) | ||
|
|
||
| assert result["tool_calls"] == returned | ||
|
|
||
| def test_untouched_tool_calls_are_echoed_not_dropped(self, generic_guardrail): | ||
| """A guardrail that ignores tool calls must not blank them out. | ||
|
|
||
| The downstream handlers compare what comes back against what they sent and fall back | ||
| to their own copy when the lengths differ, so echoing an unchanged list leaves them | ||
| behaving exactly as they did before this field existed. | ||
| """ | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| sent = [ | ||
| { | ||
| "id": "call_1", | ||
| "type": "function", | ||
| "function": {"name": "send_email", "arguments": '{"to": "jane@example.com"}'}, | ||
| } | ||
| ] | ||
|
|
||
| result = generic_guardrail._build_guardrail_return_inputs( | ||
| texts=["Done"], | ||
| images=None, | ||
| tools=None, | ||
| tool_calls=sent, | ||
| guardrail_response=GenericGuardrailAPIResponse.from_dict({"action": "NONE", "texts": ["Done"]}), | ||
| ) | ||
|
|
||
| assert result["tool_calls"] == sent | ||
|
|
||
| def test_malformed_returned_tool_calls_are_discarded(self, generic_guardrail): | ||
| """A same-length list with no function block must not reach chat handling. | ||
|
|
||
| The chat handlers index ``function.name`` and ``function.arguments`` on whatever the | ||
| guardrail returns, so forwarding an unusable list raises there instead of returning a | ||
| response. The calls that were sent are kept instead. | ||
| """ | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| sent = [{"id": "call_1", "type": "function", "function": {"name": "send_email", "arguments": "{}"}}] | ||
| malformed = [{"id": "call_1", "type": "function"}] | ||
|
|
||
| result = generic_guardrail._build_guardrail_return_inputs( | ||
| texts=["Done"], | ||
| images=None, | ||
| tools=None, | ||
| tool_calls=sent, | ||
| guardrail_response=GenericGuardrailAPIResponse.from_dict( | ||
| {"action": "GUARDRAIL_INTERVENED", "texts": ["Done"], "tool_calls": malformed} | ||
| ), | ||
| ) | ||
|
|
||
| assert result["tool_calls"] == sent | ||
|
|
||
| def test_no_tool_calls_on_either_side_stay_absent(self, generic_guardrail): | ||
| """An empty list is not the same as no list: the key must stay out of the result.""" | ||
| from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( | ||
| GenericGuardrailAPIResponse, | ||
| ) | ||
|
|
||
| result = generic_guardrail._build_guardrail_return_inputs( | ||
| texts=["Done"], | ||
| images=None, | ||
| tools=None, | ||
| guardrail_response=GenericGuardrailAPIResponse.from_dict({"action": "NONE", "texts": ["Done"]}), | ||
| ) | ||
|
|
||
| assert "tool_calls" not in result | ||
|
|
||
|
|
||
| class TestFailOnError: | ||
| """Test fail_on_error: complete fail-open on any guardrail error""" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.