From 0b19b55b3c51fd17d3abcf5b708ee348acfcdd9c Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Sun, 13 Sep 2026 13:40:18 -0500 Subject: [PATCH 1/4] fix(generic_guardrail_api): return tool_calls on the guardrail response `GenericGuardrailAPIRequest` carries `tool_calls` to the guardrail, but `GenericGuardrailAPIResponse` has no matching field and `_build_guardrail_return_inputs` never set the key, so a guardrail that rewrites tool arguments cannot hand them back. The read side already exists: - `litellm/llms/openai/chat/guardrail_translation/handler.py` reads `guardrailed_inputs.get("tool_calls", [])`, and the output path reads `returned_tool_calls` with a length guard - `litellm/llms/anthropic/chat/guardrail_translation/handler.py` reads `returned_tool_calls`, also length-guarded - `litellm/llms/openai/responses/guardrail_translation/handler.py` passes it to `_post_guardrail_tool_call_shapes`, which falls back and logs "guardrail returned no tool calls for the N scanned" whenever the key is absent Only the generic hook's write side was missing, so all of those lookups missed and a guardrail's returned tool calls were dropped. Add the field and thread it through, mirroring `tools`/`images`: the guardrail's replacements win, otherwise the calls that were sent are echoed back unchanged. Every consumer compares the returned list against the one it sent and falls back to its own copy on a length mismatch, so an unchanged echo leaves existing guardrails behaving exactly as before. --- .../generic_guardrail_api.py | 14 +- .../guardrail_hooks/generic_guardrail_api.py | 8 + .../test_generic_guardrail_api.py | 138 ++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index d8296003ae9..8114ea9aa48 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -24,14 +24,14 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.llms.openai import ChatCompletionToolParam +from litellm.types.llms.openai import ChatCompletionToolCallChunk, ChatCompletionToolParam from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIMetadata, GenericGuardrailAPIRequest, GenericGuardrailAPIResponse, GuardrailToolParam, ) -from litellm.types.utils import GenericGuardrailAPIInputs +from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -323,6 +323,7 @@ class GenericGuardrailAPI(CustomGuardrail): images: list[str] | None, tools: list[ChatCompletionToolParam] | None, guardrail_response: GenericGuardrailAPIResponse, + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, ) -> GenericGuardrailAPIInputs: # Action is NONE or no modifications needed return_inputs: Final = GenericGuardrailAPIInputs(texts=texts) @@ -336,6 +337,14 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools + # Mirrors ``tools``: the guardrail's replacements win, otherwise the calls + # are echoed back unchanged. Handlers compare the returned list against the + # one they sent and fall back to their own copy on a length mismatch, so a + # guardrail that cannot reorder them still cannot change their count. + if guardrail_response.tool_calls: + return_inputs["tool_calls"] = guardrail_response.tool_calls + elif tool_calls: + return_inputs["tool_calls"] = tool_calls if guardrail_response.stream_holdback_chars is not None: return_inputs["stream_holdback_chars"] = guardrail_response.stream_holdback_chars return return_inputs @@ -473,6 +482,7 @@ class GenericGuardrailAPI(CustomGuardrail): texts=texts, images=images, tools=tools, + tool_calls=tool_calls, guardrail_response=guardrail_response, ) diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index 4a868c48352..350e4976984 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -167,6 +167,7 @@ class GenericGuardrailAPIResponse: action: str blocked_reason: str | None stream_holdback_chars: list[int] | None + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None def __init__( self, @@ -176,6 +177,7 @@ class GenericGuardrailAPIResponse: images: list[str] | None = None, tools: list[GuardrailToolParam] | None = None, stream_holdback_chars: list[int] | None = None, + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, ) -> None: self.action = action self.blocked_reason = blocked_reason @@ -186,6 +188,11 @@ class GenericGuardrailAPIResponse: # framework must withhold from streaming emission until the next # processing round (word-boundary safety for text transformations). self.stream_holdback_chars = stream_holdback_chars + # Replacement tool calls. ``tool_calls`` is sent on the request, so the + # response mirrors it back the way ``tools`` and ``images`` are: without + # this field a guardrail that rewrites tool arguments has no channel to + # return them on, and the caller keeps the values it sent in. + self.tool_calls = tool_calls @classmethod def from_dict(cls, data: dict) -> "GenericGuardrailAPIResponse": @@ -200,4 +207,5 @@ class GenericGuardrailAPIResponse: images=data.get("images"), tools=data.get("tools"), stream_holdback_chars=stream_holdback_chars, + tool_calls=data.get("tool_calls"), ) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 83cc9ae8bb9..df672569adb 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -1380,6 +1380,35 @@ class TestGenericGuardrailAPIResponseParsing: 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,115 @@ class TestToolSupport: 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. + """ + 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_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""" From f0f06aec07565dbf7f80e9d9dc92aa456070b4d4 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Sun, 13 Sep 2026 13:54:40 -0500 Subject: [PATCH 2/4] fix(generic_guardrail_api): validate returned tool calls, drop the extra comments A same-length list of tool calls with no function block was forwarded as-is, and the chat handlers index function.name and function.arguments on whatever comes back, so an unusable list raised there instead of returning a response. Returned calls are now checked for those two fields, and the calls already sent are kept when one is missing them, which is the fallback the responses handler already takes. The new field is annotated with the request's own field type rather than spelling the list union out twice, which keeps it exact and keeps the type-discipline budget flat, and the comment blocks that restated the code are gone. --- .../generic_guardrail_api.py | 31 +++++++++++++------ .../guardrail_hooks/generic_guardrail_api.py | 14 ++++----- .../test_generic_guardrail_api.py | 26 ++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 8114ea9aa48..153b6275652 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -24,14 +24,15 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.llms.openai import ChatCompletionToolCallChunk, ChatCompletionToolParam +from litellm.types.llms.openai import ChatCompletionToolParam from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIMetadata, GenericGuardrailAPIRequest, GenericGuardrailAPIResponse, + GenericGuardrailAPIToolCalls, GuardrailToolParam, ) -from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs +from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -104,6 +105,16 @@ def _sanitize_inbound_headers( return sanitized or None +def _returned_tool_call_is_usable(tool_call: object) -> bool: + """Whether a guardrail's returned tool call carries what the chat handlers index.""" + function: Final = tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None) + if function is None: + return False + name: Final = function.get("name") if isinstance(function, dict) else getattr(function, "name", None) + arguments: Final = function.get("arguments") if isinstance(function, dict) else getattr(function, "arguments", None) + return isinstance(name, str) and isinstance(arguments, str) + + def _extract_inbound_headers( request_data: dict, logging_obj: Optional["LiteLLMLoggingObj"], @@ -323,7 +334,7 @@ class GenericGuardrailAPI(CustomGuardrail): images: list[str] | None, tools: list[ChatCompletionToolParam] | None, guardrail_response: GenericGuardrailAPIResponse, - tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, + tool_calls: GenericGuardrailAPIToolCalls | None = None, ) -> GenericGuardrailAPIInputs: # Action is NONE or no modifications needed return_inputs: Final = GenericGuardrailAPIInputs(texts=texts) @@ -337,12 +348,14 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools - # Mirrors ``tools``: the guardrail's replacements win, otherwise the calls - # are echoed back unchanged. Handlers compare the returned list against the - # one they sent and fall back to their own copy on a length mismatch, so a - # guardrail that cannot reorder them still cannot change their count. - if guardrail_response.tool_calls: - return_inputs["tool_calls"] = guardrail_response.tool_calls + returned_tool_calls: Final = guardrail_response.tool_calls + if returned_tool_calls and not all(_returned_tool_call_is_usable(call) for call in returned_tool_calls): + verbose_proxy_logger.warning( + "Generic Guardrail API returned tool calls missing a function name or arguments; keeping the ones already sent", + ) + returned_tool_calls = None # rebind-ok: an unusable reply is discarded + if returned_tool_calls: + return_inputs["tool_calls"] = returned_tool_calls elif tool_calls: return_inputs["tool_calls"] = tool_calls if guardrail_response.stream_holdback_chars is not None: diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index 350e4976984..f86d655989e 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -1,4 +1,4 @@ -from typing import Any, Final, Literal +from typing import Any, Final, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, Field from typing_extensions import TypedDict @@ -8,7 +8,9 @@ from litellm.types.llms.openai import ( ChatCompletionToolCallChunk, ) from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel -from litellm.types.utils import ChatCompletionMessageToolCall +from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs + +GenericGuardrailAPIToolCalls: TypeAlias = GenericGuardrailAPIInputs["tool_calls"] # noqa: F821 # TypedDict field reference, not a name class GuardrailToolParam(BaseModel): @@ -167,7 +169,7 @@ class GenericGuardrailAPIResponse: action: str blocked_reason: str | None stream_holdback_chars: list[int] | None - tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None + tool_calls: GenericGuardrailAPIToolCalls | None def __init__( self, @@ -177,7 +179,7 @@ class GenericGuardrailAPIResponse: images: list[str] | None = None, tools: list[GuardrailToolParam] | None = None, stream_holdback_chars: list[int] | None = None, - tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, + tool_calls: GenericGuardrailAPIToolCalls | None = None, ) -> None: self.action = action self.blocked_reason = blocked_reason @@ -188,10 +190,6 @@ class GenericGuardrailAPIResponse: # framework must withhold from streaming emission until the next # processing round (word-boundary safety for text transformations). self.stream_holdback_chars = stream_holdback_chars - # Replacement tool calls. ``tool_calls`` is sent on the request, so the - # response mirrors it back the way ``tools`` and ``images`` are: without - # this field a guardrail that rewrites tool arguments has no channel to - # return them on, and the caller keeps the values it sent in. self.tool_calls = tool_calls @classmethod diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index df672569adb..85021ab7ec2 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -1999,6 +1999,32 @@ class TestToolSupport: 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 ( From 47cf3170a2b8ee937240777916d0f2139cb6ac76 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Sun, 13 Sep 2026 20:09:12 -0500 Subject: [PATCH 3/4] fix(generic_guardrail_api): annotate the returned tool calls exactly, never rebind The type alias that named this field read the request's field through a TypedDict subscript, which ruff reads as a forward reference, and it failed the basedpyright budget. The annotations spell the union out again, which is exact and checks against GenericGuardrailAPIInputs without an alias and without a suppression comment. The unusable-reply path no longer rebinds a Final local, so it needs no suppression comment either: the calls that will be applied are picked in one expression. --- .../generic_guardrail_api.py | 20 +++++++++---------- .../guardrail_hooks/generic_guardrail_api.py | 10 ++++------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 153b6275652..9912cfcf736 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -24,15 +24,14 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.llms.openai import ChatCompletionToolParam +from litellm.types.llms.openai import ChatCompletionToolCallChunk, ChatCompletionToolParam from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIMetadata, GenericGuardrailAPIRequest, GenericGuardrailAPIResponse, - GenericGuardrailAPIToolCalls, GuardrailToolParam, ) -from litellm.types.utils import GenericGuardrailAPIInputs +from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -334,7 +333,7 @@ class GenericGuardrailAPI(CustomGuardrail): images: list[str] | None, tools: list[ChatCompletionToolParam] | None, guardrail_response: GenericGuardrailAPIResponse, - tool_calls: GenericGuardrailAPIToolCalls | None = None, + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, ) -> GenericGuardrailAPIInputs: # Action is NONE or no modifications needed return_inputs: Final = GenericGuardrailAPIInputs(texts=texts) @@ -348,16 +347,15 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools - returned_tool_calls: Final = guardrail_response.tool_calls - if returned_tool_calls and not all(_returned_tool_call_is_usable(call) for call in returned_tool_calls): + returned: Final = guardrail_response.tool_calls + usable: Final = returned is not None and all(_returned_tool_call_is_usable(call) for call in returned) + if returned and not usable: verbose_proxy_logger.warning( "Generic Guardrail API returned tool calls missing a function name or arguments; keeping the ones already sent", ) - returned_tool_calls = None # rebind-ok: an unusable reply is discarded - if returned_tool_calls: - return_inputs["tool_calls"] = returned_tool_calls - elif tool_calls: - return_inputs["tool_calls"] = tool_calls + selected: Final = returned if usable else tool_calls + if selected: + return_inputs["tool_calls"] = selected if guardrail_response.stream_holdback_chars is not None: return_inputs["stream_holdback_chars"] = guardrail_response.stream_holdback_chars return return_inputs diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index f86d655989e..fe49c279960 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -1,4 +1,4 @@ -from typing import Any, Final, Literal, TypeAlias +from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field from typing_extensions import TypedDict @@ -8,9 +8,7 @@ from litellm.types.llms.openai import ( ChatCompletionToolCallChunk, ) from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel -from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs - -GenericGuardrailAPIToolCalls: TypeAlias = GenericGuardrailAPIInputs["tool_calls"] # noqa: F821 # TypedDict field reference, not a name +from litellm.types.utils import ChatCompletionMessageToolCall class GuardrailToolParam(BaseModel): @@ -169,7 +167,7 @@ class GenericGuardrailAPIResponse: action: str blocked_reason: str | None stream_holdback_chars: list[int] | None - tool_calls: GenericGuardrailAPIToolCalls | None + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None def __init__( self, @@ -179,7 +177,7 @@ class GenericGuardrailAPIResponse: images: list[str] | None = None, tools: list[GuardrailToolParam] | None = None, stream_holdback_chars: list[int] | None = None, - tool_calls: GenericGuardrailAPIToolCalls | None = None, + tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None, ) -> None: self.action = action self.blocked_reason = blocked_reason From e5cab21b18ec6fbd53697ac07ffcd347f4123e03 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Tue, 6 Oct 2026 12:17:19 -0500 Subject: [PATCH 4/4] chore(generic_guardrail_api): drop docstrings that restate the code --- .../generic_guardrail_api.py | 1 - .../guardrail_hooks/generic_guardrail_api.py | 3 --- .../test_generic_guardrail_api.py | 27 ------------------- 3 files changed, 31 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 134e308c56c..53a2ae4369d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -340,7 +340,6 @@ class GenericGuardrailAPI(CustomGuardrail): def _echo_sent_tool_calls( guardrailed: GenericGuardrailAPIInputs, *, sent: GenericGuardrailAPIInputs ) -> GenericGuardrailAPIInputs: - """Keep the tool calls the guardrail did not return, so the handlers do not report them missing.""" sent_tool_calls: Final = sent.get("tool_calls") if not sent_tool_calls or "tool_calls" in guardrailed: return guardrailed diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index 48643192128..e055bf7f8a3 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -170,7 +170,6 @@ def structured_messages_from_response(value: object) -> Sequence[AllMessageValue def _json_object(value: object) -> Mapping[str, object] | None: - """`value` as a JSON object, or None; the keys of a JSON object are strings.""" if not isinstance(value, Mapping): return None return cast("Mapping[str, object]", value) # cast-ok: isinstance leaves a Mapping's item types unknown @@ -182,7 +181,6 @@ def _json_field(container: object, key: str) -> object: def _is_usable_tool_call(row: object) -> bool: - """Whether a returned tool call carries what the chat handlers index: a function name and string arguments.""" function: Final = _json_field(row, "function") return isinstance(_json_field(function, "name"), str) and isinstance(_json_field(function, "arguments"), str) @@ -194,7 +192,6 @@ def _json_array(value: object) -> Sequence[object] | None: def tool_calls_from_response(value: object) -> Sequence[ChatCompletionToolCallChunk] | None: - """The tool calls a guardrail handed back, or None when absent or when any row is unusable.""" rows: Final = _json_array(value) if rows is None: return None diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 9579fc77151..8f274b89f98 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -1520,7 +1520,6 @@ class TestGenericGuardrailAPIResponseParsing: 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, ) @@ -2046,13 +2045,6 @@ class TestToolSupport: @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. - """ guardrailed_tool_calls = [ { "id": "call_1", @@ -2078,12 +2070,6 @@ class TestToolSupport: 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, ) @@ -2113,12 +2099,6 @@ class TestToolSupport: @pytest.mark.asyncio async def test_untouched_tool_calls_are_echoed_not_dropped(self, generic_guardrail): - """A guardrail that ignores tool calls must not blank them out. - - The handlers compare what comes back against what they sent and fall back to their - own copy when the lengths differ, and the Responses handler logs a warning when no - list comes back. Echoing the unchanged list keeps both quiet. - """ sent = [ { "id": "call_1", @@ -2141,12 +2121,6 @@ class TestToolSupport: @pytest.mark.asyncio async 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. - """ sent = [{"id": "call_1", "type": "function", "function": {"name": "send_email", "arguments": "{}"}}] mock_response = MagicMock() mock_response.json.return_value = { @@ -2180,7 +2154,6 @@ class TestToolSupport: assert response.tool_calls is None 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, )