From 27d278f402bebe9f3a87150f5fe75bccc407edb2 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Wed, 7 Oct 2026 06:53:15 -0500 Subject: [PATCH 1/4] fix(guardrails): refuse to start when a default_on guardrail cannot be initialized A guardrail whose initialization failed was skipped with one error line and the proxy started without it. For a guardrail that is default_on that is fail-open: it is meant to run on every request, so every request reaches the provider carrying whatever it was configured to block or redact, and the only sign is the startup line. The usual way to hit it is a config written for a newer LiteLLM, where the guardrail type is unknown to the running release. init_guardrails_v2 now raises for a default_on guardrail that cannot be initialized, naming the guardrail and the cause, so the proxy stops. Guardrails that are not default_on keep the existing skip: nothing runs them unless a request names them. Closes #45028 --- litellm/proxy/guardrails/init_guardrails.py | 19 ++++++ .../proxy/guardrails/test_init_guardrails.py | 68 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/litellm/proxy/guardrails/init_guardrails.py b/litellm/proxy/guardrails/init_guardrails.py index f35a7a45c4e..6244aedcbce 100644 --- a/litellm/proxy/guardrails/init_guardrails.py +++ b/litellm/proxy/guardrails/init_guardrails.py @@ -34,6 +34,17 @@ def init_guardrails_v2( source="config", ) except (ValueError, TypeError) as init_error: + if _is_default_on(guardrail): + # A default_on guardrail runs on every request. Starting without it is + # fail-open: every request reaches the provider carrying whatever this + # guardrail was configured to block or redact, and the only sign is one + # log line at startup. That is the case for a config written for a newer + # LiteLLM (a guardrail type this release does not know) as much as for a + # bad parameter, so the proxy stops here instead. + raise ValueError( + f"Guardrail '{guardrail.get('guardrail_name')}' is default_on and could not be " + f"initialized, so the proxy will not start without it: {init_error}" + ) from init_error verbose_proxy_logger.error( "Skipping guardrail '%s': invalid configuration, proxy is starting WITHOUT this guardrail: %s", guardrail.get("guardrail_name"), @@ -49,6 +60,14 @@ def init_guardrails_v2( _populate_router_guardrail_list(guardrail_list=guardrail_list) +def _is_default_on(guardrail: dict) -> bool: + """Whether a config entry asks to run on every request (`litellm_params.default_on`).""" + litellm_params = guardrail.get("litellm_params") + if isinstance(litellm_params, dict): + return litellm_params.get("default_on") is True + return getattr(litellm_params, "default_on", None) is True + + def _populate_router_guardrail_list(guardrail_list: list[Guardrail]) -> None: """ Populate the router's guardrail_list from initialized guardrails. diff --git a/tests/unit/proxy/guardrails/test_init_guardrails.py b/tests/unit/proxy/guardrails/test_init_guardrails.py index 2cac5d60068..dca7542f5ac 100644 --- a/tests/unit/proxy/guardrails/test_init_guardrails.py +++ b/tests/unit/proxy/guardrails/test_init_guardrails.py @@ -371,6 +371,74 @@ def test_init_guardrails_v2_skips_invalid_guardrail_instead_of_crashing_boot(): assert "healthy_presidio" in guardrail_names +def test_init_guardrails_v2_stops_boot_when_a_default_on_guardrail_cannot_be_initialized(): + """ + BerriAI/litellm#45028: a `default_on` guardrail whose type this release does not know + (a config written for a newer LiteLLM, for example) used to be skipped with one error + line, and the proxy started without it. For a guardrail that is meant to run on every + request that is fail-open: every request reaches the provider carrying whatever the + guardrail was configured to withhold. Initialization of a default_on guardrail is fatal. + """ + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER + + IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.clear() + IN_MEMORY_GUARDRAIL_HANDLER.guardrail_id_to_custom_guardrail.clear() + + all_guardrails = [ + { + "guardrail_name": "pii-redaction", + "litellm_params": { + "guardrail": "a_guardrail_type_this_release_does_not_know", + "mode": "pre_call", + "default_on": True, + }, + }, + ] + + with pytest.raises(ValueError, match="'pii-redaction' is default_on and could not be initialized"): + init_guardrails_v2(all_guardrails=all_guardrails) + + +def test_init_guardrails_v2_still_skips_an_optional_guardrail_it_cannot_initialize(): + """ + The skip (#34940) stays for guardrails that are not default_on: nothing runs them + unless a request names them, so starting without one withholds nothing by default. + The healthy guardrail beside it is still registered. + """ + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER + + IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.clear() + IN_MEMORY_GUARDRAIL_HANDLER.guardrail_id_to_custom_guardrail.clear() + + all_guardrails = [ + { + "guardrail_name": "optional-unknown", + "litellm_params": { + "guardrail": "a_guardrail_type_this_release_does_not_know", + "mode": "pre_call", + "default_on": False, + }, + }, + { + "guardrail_name": "healthy_presidio", + "litellm_params": { + "guardrail": SupportedGuardrailIntegrations.PRESIDIO.value, + "mode": "pre_call", + "presidio_analyzer_api_base": "https://fakelink.com/v1/presidio/analyze", + "presidio_anonymizer_api_base": "https://fakelink.com/v1/presidio/anonymize", + }, + }, + ] + + init_guardrails_v2(all_guardrails=all_guardrails) + + guardrail_names = { + guardrail["guardrail_name"] for guardrail in IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.values() + } + assert "optional-unknown" not in guardrail_names + assert "healthy_presidio" in guardrail_names + + def test_init_guardrails_v2_stops_boot_when_a_custom_code_guardrail_does_not_compile(): from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER From 3d0fbda3b1ff46d04e86940e2c27c0f2271be9c8 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Wed, 7 Oct 2026 07:02:41 -0500 Subject: [PATCH 2/4] fix(guardrails): read default_on the way LitellmParams parses it; typed helper, no comment default_on reaches the check raw, before LitellmParams parses it, so "true", 1 and "yes" now count as true like they do after parsing; a default_on spelled as a YAML string was still being skipped. The helper takes a Mapping and annotates its locals; the explanatory comment is gone, the commit message carries it. --- litellm/proxy/guardrails/init_guardrails.py | 35 ++++++++++++------- .../proxy/guardrails/test_init_guardrails.py | 25 +++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/guardrails/init_guardrails.py b/litellm/proxy/guardrails/init_guardrails.py index 6244aedcbce..39192cf577a 100644 --- a/litellm/proxy/guardrails/init_guardrails.py +++ b/litellm/proxy/guardrails/init_guardrails.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Final, cast import litellm @@ -35,12 +36,6 @@ def init_guardrails_v2( ) except (ValueError, TypeError) as init_error: if _is_default_on(guardrail): - # A default_on guardrail runs on every request. Starting without it is - # fail-open: every request reaches the provider carrying whatever this - # guardrail was configured to block or redact, and the only sign is one - # log line at startup. That is the case for a config written for a newer - # LiteLLM (a guardrail type this release does not know) as much as for a - # bad parameter, so the proxy stops here instead. raise ValueError( f"Guardrail '{guardrail.get('guardrail_name')}' is default_on and could not be " f"initialized, so the proxy will not start without it: {init_error}" @@ -60,12 +55,28 @@ def init_guardrails_v2( _populate_router_guardrail_list(guardrail_list=guardrail_list) -def _is_default_on(guardrail: dict) -> bool: - """Whether a config entry asks to run on every request (`litellm_params.default_on`).""" - litellm_params = guardrail.get("litellm_params") - if isinstance(litellm_params, dict): - return litellm_params.get("default_on") is True - return getattr(litellm_params, "default_on", None) is True +_TRUE_WORDS: Final = frozenset({"true", "1", "yes", "on", "t", "y"}) + + +def _is_default_on(guardrail: Mapping[str, object]) -> bool: + """Whether a config entry asks to run on every request (`litellm_params.default_on`). + + Read from the raw config, before `LitellmParams` has parsed it, so the same values + pydantic would accept as true (`true`, `"true"`, `1`, `"yes"`) count as true here. + """ + litellm_params: Final = guardrail.get("litellm_params") + value: Final = ( + litellm_params.get("default_on") + if isinstance(litellm_params, Mapping) + else getattr(litellm_params, "default_on", None) + ) + if isinstance(value, bool): + return value + if isinstance(value, int): + return value == 1 + if isinstance(value, str): + return value.strip().lower() in _TRUE_WORDS + return False def _populate_router_guardrail_list(guardrail_list: list[Guardrail]) -> None: diff --git a/tests/unit/proxy/guardrails/test_init_guardrails.py b/tests/unit/proxy/guardrails/test_init_guardrails.py index dca7542f5ac..167a6a4fa69 100644 --- a/tests/unit/proxy/guardrails/test_init_guardrails.py +++ b/tests/unit/proxy/guardrails/test_init_guardrails.py @@ -399,6 +399,31 @@ def test_init_guardrails_v2_stops_boot_when_a_default_on_guardrail_cannot_be_ini init_guardrails_v2(all_guardrails=all_guardrails) +@pytest.mark.parametrize("default_on", [True, "true", "True", 1, "1", "yes"]) +def test_default_on_is_read_the_way_litellm_params_would_parse_it(default_on): + """The config value reaches this check raw, before LitellmParams parses it. Every spelling + pydantic accepts as true must stop the boot too; `default_on: "true"` from a YAML string + is as required as `default_on: true`.""" + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER + + IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.clear() + IN_MEMORY_GUARDRAIL_HANDLER.guardrail_id_to_custom_guardrail.clear() + + all_guardrails = [ + { + "guardrail_name": "pii-redaction", + "litellm_params": { + "guardrail": "a_guardrail_type_this_release_does_not_know", + "mode": "pre_call", + "default_on": default_on, + }, + }, + ] + + with pytest.raises(ValueError, match="is default_on and could not be initialized"): + init_guardrails_v2(all_guardrails=all_guardrails) + + def test_init_guardrails_v2_still_skips_an_optional_guardrail_it_cannot_initialize(): """ The skip (#34940) stays for guardrails that are not default_on: nothing runs them From 5510f3435bbe542f997472e9bea1c84ce876e4eb Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Wed, 7 Oct 2026 07:24:01 -0500 Subject: [PATCH 3/4] test(guardrails): cover default_on read from a parsed LitellmParams and from odd values --- .../unit/proxy/guardrails/test_init_guardrails.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/proxy/guardrails/test_init_guardrails.py b/tests/unit/proxy/guardrails/test_init_guardrails.py index 167a6a4fa69..0f90221604d 100644 --- a/tests/unit/proxy/guardrails/test_init_guardrails.py +++ b/tests/unit/proxy/guardrails/test_init_guardrails.py @@ -424,6 +424,20 @@ def test_default_on_is_read_the_way_litellm_params_would_parse_it(default_on): init_guardrails_v2(all_guardrails=all_guardrails) +def test_default_on_is_read_from_a_parsed_litellm_params_object_too(): + """A config entry can arrive with `litellm_params` already parsed into a model, not a dict; + the attribute is read the same way. `None` and an unknown spelling count as not default_on.""" + from litellm.proxy.guardrails.init_guardrails import _is_default_on + from litellm.types.guardrails import LitellmParams + + parsed = LitellmParams(guardrail="presidio", mode="pre_call", default_on=True) + assert _is_default_on({"litellm_params": parsed}) is True + assert _is_default_on({"litellm_params": LitellmParams(guardrail="presidio", mode="pre_call")}) is False + assert _is_default_on({"litellm_params": {"default_on": None}}) is False + assert _is_default_on({"litellm_params": {"default_on": "sometimes"}}) is False + assert _is_default_on({}) is False + + def test_init_guardrails_v2_still_skips_an_optional_guardrail_it_cannot_initialize(): """ The skip (#34940) stays for guardrails that are not default_on: nothing runs them From 0f7e214168758da46f12d1dc51c737285ba86254 Mon Sep 17 00:00:00 2001 From: Ninad Phalak Date: Thu, 15 Oct 2026 22:11:10 -0500 Subject: [PATCH 4/4] fix(guardrails): keep basedpyright counts at the merge base in the default_on check --- litellm/proxy/guardrails/init_guardrails.py | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/guardrails/init_guardrails.py b/litellm/proxy/guardrails/init_guardrails.py index 39192cf577a..a0327c5a8a2 100644 --- a/litellm/proxy/guardrails/init_guardrails.py +++ b/litellm/proxy/guardrails/init_guardrails.py @@ -35,11 +35,8 @@ def init_guardrails_v2( source="config", ) except (ValueError, TypeError) as init_error: - if _is_default_on(guardrail): - raise ValueError( - f"Guardrail '{guardrail.get('guardrail_name')}' is default_on and could not be " - f"initialized, so the proxy will not start without it: {init_error}" - ) from init_error + entry = cast("Mapping[str, object]", guardrail) # cast-ok: config entries are str-keyed mappings + _raise_if_default_on(entry, init_error) verbose_proxy_logger.error( "Skipping guardrail '%s': invalid configuration, proxy is starting WITHOUT this guardrail: %s", guardrail.get("guardrail_name"), @@ -58,18 +55,27 @@ def init_guardrails_v2( _TRUE_WORDS: Final = frozenset({"true", "1", "yes", "on", "t", "y"}) +def _raise_if_default_on(guardrail: Mapping[str, object], init_error: Exception) -> None: + if _is_default_on(guardrail): + raise ValueError( + f"Guardrail '{guardrail.get('guardrail_name')}' is default_on and could not be " + f"initialized, so the proxy will not start without it: {init_error}" + ) from init_error + + +def _config_value(entry: object, key: str) -> object: + if isinstance(entry, Mapping): + return cast("Mapping[str, object]", entry).get(key) # cast-ok: config mappings are str-keyed + return getattr(entry, key, None) + + def _is_default_on(guardrail: Mapping[str, object]) -> bool: """Whether a config entry asks to run on every request (`litellm_params.default_on`). Read from the raw config, before `LitellmParams` has parsed it, so the same values pydantic would accept as true (`true`, `"true"`, `1`, `"yes"`) count as true here. """ - litellm_params: Final = guardrail.get("litellm_params") - value: Final = ( - litellm_params.get("default_on") - if isinstance(litellm_params, Mapping) - else getattr(litellm_params, "default_on", None) - ) + value: Final = _config_value(guardrail.get("litellm_params"), "default_on") if isinstance(value, bool): return value if isinstance(value, int):