diff --git a/litellm/proxy/guardrails/init_guardrails.py b/litellm/proxy/guardrails/init_guardrails.py index f35a7a45c4e..a0327c5a8a2 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 @@ -34,6 +35,8 @@ def init_guardrails_v2( source="config", ) except (ValueError, TypeError) as 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"), @@ -49,6 +52,39 @@ def init_guardrails_v2( _populate_router_guardrail_list(guardrail_list=guardrail_list) +_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. + """ + value: Final = _config_value(guardrail.get("litellm_params"), "default_on") + 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: """ 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 daf7ef1fd05..5f70e5de3a0 100644 --- a/tests/unit/proxy/guardrails/test_init_guardrails.py +++ b/tests/unit/proxy/guardrails/test_init_guardrails.py @@ -371,6 +371,113 @@ 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) + + +@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_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 + 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_keeps_guardrail_whose_env_values_are_blank(): """ Regression: a config.yaml guardrail with `default_on: os.environ/GUARDRAIL_DEFAULT_ON` and