From d4a89bad1b331c05a54ae2efe9016c35c8971075 Mon Sep 17 00:00:00 2001 From: Scott Thornton <38704968+scthornton@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:25:05 -0400 Subject: [PATCH] fix(panw_prisma_airs): coerce `timeout` to float so string config values do not crash the handler (#28594) * fix(panw_prisma_airs): coerce timeout to float so string config values do not crash the handler When the panw_prisma_airs guardrail receives timeout as a string (which is what the dashboard UI persists to the DB, and what raw YAML preserves if the value is quoted), every request fails with a misleading "500 Security scan failed - request blocked for safety". No request is actually sent to AIRS; the string survives into httpx.AsyncClient.post, which raises TypeError on its internal '<=' comparison. The broad except in apply_guardrail buckets it as api_error and the proxy wraps that as the safety-themed 500. Two changes: 1) PanwPrismaAirsHandler.__init__ now coerces timeout defensively. Guards every init path (registry-based initializer, legacy initializer, direct instantiation). 2) BaseLitellmParams declares timeout: Optional[float] with a mode="before" validator that coerces strings to float. Fixes the same class of bug at the API boundary for any guardrail provider that pulls timeout from litellm_params via model_dump(). Adds regression tests in TestPanwAirsTimeoutCoercion covering both the handler init and the Pydantic boundary. No public API changes. Correctly-typed callers (timeout: 30) behave identically. Fixes #28540 * fix(panw_prisma_airs): handle None timeout in legacy initializer after schema change Greptile flagged a regression on PR #28594: declaring timeout: Optional[float] = None on BaseLitellmParams means the attribute is now always present (defaulting to None), which breaks the legacy initializer at guardrail_initializers.py:220. Before this commit: timeout=float(getattr(litellm_params, "timeout", 10.0)) The getattr default 10.0 was only returned when the attribute was absent. Now the attribute exists as None, so getattr returns None, and float(None) raises TypeError. Every PANW deployment that does not explicitly set timeout would crash on init. This change unwraps the getattr-then-coerce into an explicit None check that preserves the 10.0 default semantics while remaining safe under the new schema. Adds two regression tests: - test_legacy_initializer_handles_unset_timeout: exercises the exact crash path through initialize_panw_prisma_airs with no timeout provided. - test_litellm_params_empty_string_timeout_becomes_none: covers the validator's empty-string branch (dashboard form can send ""). --- .../panw_prisma_airs/panw_prisma_airs.py | 7 +- .../guardrails/guardrail_initializers.py | 10 ++- litellm/types/guardrails.py | 21 +++++ .../guardrail_hooks/test_panw_prisma_airs.py | 88 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index bbffc70ddbf..e5200394b55 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -140,7 +140,12 @@ class PanwPrismaAirsHandler(CustomGuardrail): ) self.fallback_on_error = fallback_on_error - self.timeout = timeout + # Coerce defensively. The dashboard UI persists this field as a JSON + # string, and Pydantic extras (the path that splats model_dump into + # this handler) preserve whatever type the user supplied. A string + # value would otherwise reach httpx, which raises TypeError on its + # internal '<=' comparison and surfaces as a misleading api_error. + self.timeout = float(timeout) if timeout is not None else 10.0 # Tri-state: None = not set (default-on for Anthropic), True = explicit on, False = explicit off self.experimental_use_latest_role_message_only: Optional[bool] = kwargs.get( diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index 109f2237165..9af43950837 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -217,7 +217,15 @@ def initialize_panw_prisma_airs(litellm_params, guardrail): mask_response_content=getattr(litellm_params, "mask_response_content", False), app_name=getattr(litellm_params, "app_name", None), fallback_on_error=getattr(litellm_params, "fallback_on_error", "block"), - timeout=float(getattr(litellm_params, "timeout", 10.0)), + # `timeout` is now declared on BaseLitellmParams (Optional[float] = None), + # so the attribute always exists. The Pydantic validator on LitellmParams + # coerces strings to float, but None still means "use handler default" — + # guard against float(None) here. + timeout=( + float(getattr(litellm_params, "timeout", None)) + if getattr(litellm_params, "timeout", None) is not None + else 10.0 + ), violation_message_template=litellm_params.violation_message_template, ) litellm.logging_callback_manager.add_litellm_callback(_panw_callback) diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 9aa9fad41d7..47533cefbea 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -761,6 +761,15 @@ class BaseLitellmParams( description="Python-like code containing the apply_guardrail function for custom guardrail logic", ) + timeout: Optional[float] = Field( + default=None, + description=( + "Per-request timeout for the guardrail provider API call (seconds). " + "Accepts int, float, or numeric string; coerced to float on load. " + "Each guardrail handler chooses its own default when unset." + ), + ) + model_config = ConfigDict(extra="allow", protected_namespaces=()) @@ -818,6 +827,18 @@ class LitellmParams( return [x.lower() if isinstance(x, str) else x for x in v] return v + @field_validator("timeout", mode="before", check_fields=False) + @classmethod + def coerce_timeout(cls, v): + """Accept string-valued timeouts (dashboard UI sends JSON strings) + and coerce to float before any handler reads the value.""" + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError) as e: + raise ValueError(f"timeout must be numeric, got {v!r}") from e + def __init__(self, **kwargs): default_on = kwargs.pop("default_on", None) if default_on is not None: diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index 5c60e3e2bd8..431a7aa6f02 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -5375,5 +5375,93 @@ class TestPanwAirsDualScanIndependence: assert mcp_call.get("content") is None +class TestPanwAirsTimeoutCoercion: + """Regression tests for string-valued timeout handling. + + Before the fix, a string `timeout` (which is what the dashboard UI persists + and what raw YAML preserves if quoted) survived into httpx, which raised + `TypeError: '<=' not supported between instances of 'str' and 'int'`. The + broad except in apply_guardrail swallowed it and the proxy returned a + misleading 500 'Security scan failed - request blocked for safety'. + """ + + def test_handler_coerces_string_timeout_to_float(self): + handler = make_handler(timeout="30") + assert handler.timeout == 30.0 + assert isinstance(handler.timeout, float) + + def test_handler_accepts_int_timeout(self): + handler = make_handler(timeout=15) + assert handler.timeout == 15.0 + + def test_handler_accepts_float_timeout(self): + handler = make_handler(timeout=7.5) + assert handler.timeout == 7.5 + + def test_handler_none_timeout_falls_back_to_default(self): + handler = make_handler(timeout=None) + assert handler.timeout == 10.0 + + def test_handler_omitted_timeout_uses_default(self): + handler = make_handler() + assert handler.timeout == 10.0 + + def test_litellm_params_coerces_string_timeout(self): + """Boundary validation: the Pydantic model itself should normalize + string timeouts before any handler reads the value via model_dump().""" + params = LitellmParams( + guardrail="panw_prisma_airs", + mode="pre_call", + api_key="test_key", + profile_name="test_profile", + timeout="30", + ) + assert params.timeout == 30.0 + assert isinstance(params.timeout, float) + + def test_litellm_params_rejects_garbage_timeout(self): + with pytest.raises(ValueError): + LitellmParams( + guardrail="panw_prisma_airs", + mode="pre_call", + api_key="test_key", + profile_name="test_profile", + timeout="not-a-number", + ) + + def test_litellm_params_empty_string_timeout_becomes_none(self): + """Empty-string timeout (which the dashboard form can send) should + be coerced to None, not crash, and not produce float('').""" + params = LitellmParams( + guardrail="panw_prisma_airs", + mode="pre_call", + api_key="test_key", + profile_name="test_profile", + timeout="", + ) + assert params.timeout is None + + def test_legacy_initializer_handles_unset_timeout(self): + """Regression guard: with timeout now a declared Optional[float] = None + on BaseLitellmParams, the legacy panw initializer at + guardrail_initializers.py:220 must not crash on float(None) when the + caller omits timeout entirely.""" + from litellm.proxy.guardrails.guardrail_initializers import ( + initialize_panw_prisma_airs, + ) + + params = LitellmParams( + guardrail="panw_prisma_airs", + mode="pre_call", + api_key="test_key", + profile_name="test_profile", + # timeout intentionally omitted - field defaults to None + ) + guardrail_config = {"guardrail_name": "test_legacy"} + handler = initialize_panw_prisma_airs(params, guardrail_config) + # Default fallback applied, not crashed on float(None) + assert handler.timeout == 10.0 + + if __name__ == "__main__": pytest.main([__file__, "-v"])