From b45175e1af1b12752b1e5a67ab3b4e9d67106ef5 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Thu, 24 Sep 2026 01:38:47 +0800 Subject: [PATCH 1/2] fix(guardrails): accept Gemini-native tools without a type field GuardrailToolParam required type: str, so payloads like {"googleSearch": {}} failed validation under generic_guardrail_api / headroom with default_on and surfaced as a 500 before the guardrail ran. Make type optional and omit a null type on serialize so provider-native tools are forwarded verbatim. Fixes #42742 --- .../guardrail_hooks/generic_guardrail_api.py | 19 ++++++++--- .../test_generic_guardrail_api.py | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) 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 44e2cc2404f..e2c9a75b0b2 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -1,7 +1,7 @@ from collections.abc import Mapping, Sequence from typing import Any, Final, Literal, cast # noqa: TID251 # JSON chat rows have no typed constructor across roles -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_serializer from typing_extensions import TypedDict from litellm.types.llms.openai import ( @@ -15,13 +15,22 @@ from litellm.types.utils import ChatCompletionMessageToolCall class GuardrailToolParam(BaseModel): """A tool forwarded verbatim to the guardrail for inspection. - Built-in tools (code_interpreter, file_search, ...) have no ``function`` block - and stash their config in tool-specific keys, so only ``type`` is required and - ``extra="allow"`` preserves the rest instead of stripping it. + OpenAI-style tools carry a ``type`` (function / code_interpreter / file_search, + ...). Provider-native tools such as Gemini ``{"googleSearch": {}}`` have neither + ``type`` nor ``function``; ``extra="allow"`` keeps their keys intact so the + guardrail still sees the original payload. ``type`` is therefore optional. """ model_config = ConfigDict(extra="allow") - type: str + type: str | None = None + + @model_serializer(mode="wrap") + def _omit_null_type(self, handler): + """Keep provider-native tools free of a synthetic ``type: null`` field.""" + data = handler(self) + if isinstance(data, dict) and data.get("type") is None: + data.pop("type", None) + return data class GenericGuardrailAPIMetadata(TypedDict, total=False): 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 a5e79f84ef1..27ab601b00d 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 @@ -2015,6 +2015,40 @@ class TestToolSupport: assert forwarded_tools == tools + @pytest.mark.asyncio + async def test_gemini_native_tools_without_type_do_not_crash( + self, generic_guardrail + ): + """Gemini-native tools have no ``type`` key at all (e.g. googleSearch). + + Regression for #42742: GuardrailToolParam required ``type: str``, so + validating ``{"googleSearch": {}}`` raised before the guardrail ran and + surfaced as a 500 under default_on. The payload must still be forwarded + verbatim (no synthetic ``type: null``). + """ + tools = [ + {"googleSearch": {}}, + {"codeExecution": {}}, + {"type": "function", "function": {"name": "get_weather", "parameters": {}}}, + ] + + mock_response = MagicMock() + mock_response.json.return_value = {"action": "NONE", "texts": ["hi"]} + mock_response.raise_for_status = MagicMock() + + with patch.object( + generic_guardrail.async_handler, "post", return_value=mock_response + ) as mock_post: + await generic_guardrail.apply_guardrail( + inputs={"texts": ["hi"], "tools": tools}, + request_data={}, + input_type="request", + ) + + forwarded_tools = mock_post.call_args.kwargs["json"]["tools"] + + assert forwarded_tools == tools + class TestFailOnError: """Test fail_on_error: complete fail-open on any guardrail error""" From aa320825d5a94324f591a4518d6f6f3cda6e9b91 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sun, 27 Sep 2026 17:07:01 +0000 Subject: [PATCH 2/2] fix(guardrails): omit null type immutably; trim docstrings --- .../guardrail_hooks/generic_guardrail_api.py | 23 ++++++++++--------- .../test_generic_guardrail_api.py | 8 +------ 2 files changed, 13 insertions(+), 18 deletions(-) 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 e2c9a75b0b2..f33db7a42bd 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 collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any, Final, Literal, cast # noqa: TID251 # JSON chat rows have no typed constructor across roles from pydantic import BaseModel, ConfigDict, Field, model_serializer @@ -15,22 +15,23 @@ from litellm.types.utils import ChatCompletionMessageToolCall class GuardrailToolParam(BaseModel): """A tool forwarded verbatim to the guardrail for inspection. - OpenAI-style tools carry a ``type`` (function / code_interpreter / file_search, - ...). Provider-native tools such as Gemini ``{"googleSearch": {}}`` have neither - ``type`` nor ``function``; ``extra="allow"`` keeps their keys intact so the - guardrail still sees the original payload. ``type`` is therefore optional. + ``extra="allow"`` keeps provider-specific keys. ``type`` is optional for + provider-native tools such as Gemini ``{"googleSearch": {}}``. """ model_config = ConfigDict(extra="allow") type: str | None = None @model_serializer(mode="wrap") - def _omit_null_type(self, handler): - """Keep provider-native tools free of a synthetic ``type: null`` field.""" - data = handler(self) - if isinstance(data, dict) and data.get("type") is None: - data.pop("type", None) - return data + def _omit_null_type( # noqa: ANN202 # annotating it replaces the model's serialization schema + self, handler: Callable[[object], Mapping[str, object]] + ): + data: Final[Mapping[str, object]] = handler(self) + if not isinstance(data, dict) or data.get("type") is not None: + return data + return { # mutable-ok: pydantic's json serializer rejects a mapping that is not a dict + key: value for key, value in data.items() if key != "type" + } class GenericGuardrailAPIMetadata(TypedDict, total=False): 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 27ab601b00d..818dfe2878b 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 @@ -2019,13 +2019,7 @@ class TestToolSupport: async def test_gemini_native_tools_without_type_do_not_crash( self, generic_guardrail ): - """Gemini-native tools have no ``type`` key at all (e.g. googleSearch). - - Regression for #42742: GuardrailToolParam required ``type: str``, so - validating ``{"googleSearch": {}}`` raised before the guardrail ran and - surfaced as a 500 under default_on. The payload must still be forwarded - verbatim (no synthetic ``type: null``). - """ + """Gemini-native tools without type are forwarded unchanged.""" tools = [ {"googleSearch": {}}, {"codeExecution": {}},