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 "").
This commit is contained in:
Scott Thornton 2026-06-01 06:25:05 -04:00 • committed by GitHub
parent 3526d14be9
commit d4a89bad1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 2 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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:

View file

@ -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"])