mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-19 16:19:29 +00:00
fix(guardrails): prevent presidio crash on non-json responses
This commit is contained in:
@@ -592,6 +592,21 @@ def test_pii_masking_allows_normal_text():
|
||||
|
||||
## Part 7: Troubleshooting
|
||||
|
||||
### Issue: Guardrail failure: non-JSON response from Presidio
|
||||
|
||||
**Symptom:** You receive an error indicating `expected application/json Content-Type but received text/html` or similar.
|
||||
|
||||
**Root cause:** Your ingress controller or reverse proxy might be routing the `/analyze` or `/anonymize` POST request to a health endpoint (like `/health` or `/presidio-analyzer/health`) which returns plain text instead of JSON.
|
||||
|
||||
**Fix:** Ensure your `PRESIDIO_ANALYZER_API_BASE` and `PRESIDIO_ANONYMIZER_API_BASE` are correctly pointing directly to the Presidio API endpoints, or that your ingress routes the path correctly without stripping it and inadvertently forwarding to a plain-text health check endpoint.
|
||||
|
||||
**Verification:** You can verify your endpoints using `curl`. It should return a JSON array, not `text/html`:
|
||||
```bash
|
||||
curl -sv -X POST http://your-analyzer-endpoint/analyze \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"test","language":"en"}'
|
||||
```
|
||||
|
||||
### Issue: Presidio Not Detecting PII
|
||||
|
||||
**Check 1: Language Configuration**
|
||||
|
||||
@@ -322,12 +322,6 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
||||
analyze_payload,
|
||||
)
|
||||
|
||||
async with session.post(analyze_url, json=analyze_payload) as response:
|
||||
analyze_results = await response.json()
|
||||
verbose_proxy_logger.debug("analyze_results: %s", analyze_results)
|
||||
|
||||
# Handle error responses from Presidio (e.g., {'error': 'No text provided'})
|
||||
# Presidio may return a dict instead of a list when errors occur
|
||||
def _fail_on_invalid_response(
|
||||
reason: str,
|
||||
) -> List[PresidioAnalyzeResponseItem]:
|
||||
@@ -347,6 +341,36 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
||||
)
|
||||
return []
|
||||
|
||||
async with session.post(
|
||||
analyze_url,
|
||||
json=analyze_payload,
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
# Validate HTTP status
|
||||
if response.status >= 400:
|
||||
error_body = await response.text()
|
||||
return _fail_on_invalid_response(
|
||||
f"HTTP {response.status} from Presidio analyzer: {error_body[:200]}"
|
||||
)
|
||||
|
||||
# Validate Content-Type is JSON
|
||||
content_type = getattr(
|
||||
response,
|
||||
"content_type",
|
||||
response.headers.get("Content-Type", ""),
|
||||
)
|
||||
if "application/json" not in content_type:
|
||||
error_body = await response.text()
|
||||
return _fail_on_invalid_response(
|
||||
f"expected application/json Content-Type but received '{content_type}'; body: '{error_body[:200]}'"
|
||||
)
|
||||
|
||||
analyze_results = await response.json()
|
||||
verbose_proxy_logger.debug("analyze_results: %s", analyze_results)
|
||||
|
||||
# Handle error responses from Presidio (e.g., {'error': 'No text provided'})
|
||||
# Presidio may return a dict instead of a list when errors occur
|
||||
|
||||
if isinstance(analyze_results, dict):
|
||||
if "error" in analyze_results:
|
||||
return _fail_on_invalid_response(
|
||||
@@ -423,8 +447,29 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
anonymize_url, json=anonymize_payload
|
||||
anonymize_url,
|
||||
json=anonymize_payload,
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
# Validate HTTP status
|
||||
if response.status >= 400:
|
||||
error_body = await response.text()
|
||||
raise Exception(
|
||||
f"Presidio anonymizer returned HTTP {response.status}: {error_body[:200]}"
|
||||
)
|
||||
|
||||
# Validate Content-Type is JSON
|
||||
content_type = getattr(
|
||||
response,
|
||||
"content_type",
|
||||
response.headers.get("Content-Type", ""),
|
||||
)
|
||||
if "application/json" not in content_type:
|
||||
error_body = await response.text()
|
||||
raise Exception(
|
||||
f"Presidio anonymizer returned non-JSON Content-Type '{content_type}'; body: '{error_body[:200]}'"
|
||||
)
|
||||
|
||||
redacted_text = await response.json()
|
||||
|
||||
new_text = text
|
||||
@@ -456,7 +501,11 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
||||
except Exception as e:
|
||||
# Sanitize exception to avoid leaking the original text (which may
|
||||
# contain API keys or other secrets) in error responses.
|
||||
if "Invalid anonymizer response" in str(e):
|
||||
error_str = str(e)
|
||||
if (
|
||||
"Invalid anonymizer response" in error_str
|
||||
or "Presidio anonymizer returned" in error_str
|
||||
):
|
||||
raise
|
||||
raise Exception(
|
||||
f"Presidio PII anonymization failed: {type(e).__name__}"
|
||||
|
||||
@@ -24,12 +24,29 @@ from litellm.types.guardrails import LitellmParams, PiiAction, PiiEntityType
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
|
||||
|
||||
def _make_mock_session_iterator(json_response):
|
||||
def _make_mock_session_iterator(
|
||||
json_response, status=200, content_type="application/json", text_response=""
|
||||
):
|
||||
"""Create a mock _get_session_iterator that yields a session returning json_response."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_iterator():
|
||||
class MockResponse:
|
||||
def __init__(self):
|
||||
self.status = status
|
||||
self.content_type = content_type
|
||||
self.headers = {"Content-Type": content_type}
|
||||
|
||||
async def text(self):
|
||||
if text_response:
|
||||
return text_response
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(json_response)
|
||||
except Exception:
|
||||
return str(json_response)
|
||||
|
||||
async def json(self):
|
||||
return json_response
|
||||
|
||||
@@ -41,6 +58,7 @@ def _make_mock_session_iterator(json_response):
|
||||
|
||||
class MockSession:
|
||||
def post(self, *args, **kwargs):
|
||||
self.last_kwargs = kwargs
|
||||
return MockResponse()
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -1444,3 +1462,149 @@ def test_deny_list_and_score_threshold_combined():
|
||||
# EMAIL_ADDRESS passes both filters
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["entity_type"] == "EMAIL_ADDRESS"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_text_non_json_content_type_fail_closed():
|
||||
"""
|
||||
Test that analyze_text raises GuardrailRaisedException when Presidio health
|
||||
endpoint returns text/html and fail-closed is enabled.
|
||||
"""
|
||||
guardrail = _OPTIONAL_PresidioPIIMasking(
|
||||
presidio_analyzer_api_base="http://test-analyzer/",
|
||||
presidio_anonymizer_api_base="http://test-anonymizer/",
|
||||
pii_entities_config={"PERSON": PiiAction.BLOCK},
|
||||
mock_testing=False,
|
||||
)
|
||||
|
||||
mock_iterator = _make_mock_session_iterator(
|
||||
json_response=None,
|
||||
status=200,
|
||||
content_type="text/html; charset=utf-8",
|
||||
text_response="Presidio Analyzer service is up.",
|
||||
)
|
||||
|
||||
with patch.object(guardrail, "_get_session_iterator", mock_iterator):
|
||||
with pytest.raises(GuardrailRaisedException) as exc_info:
|
||||
await guardrail.analyze_text(
|
||||
text="Hello world",
|
||||
presidio_config=None,
|
||||
request_data={},
|
||||
)
|
||||
assert "expected application/json Content-Type" in str(exc_info.value)
|
||||
assert "text/html" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_text_non_json_content_type_fail_open():
|
||||
"""
|
||||
Test that analyze_text returns empty list when Presidio returns text/html
|
||||
and fail-closed is NOT enabled.
|
||||
"""
|
||||
guardrail = _OPTIONAL_PresidioPIIMasking(
|
||||
presidio_analyzer_api_base="http://test-analyzer/",
|
||||
presidio_anonymizer_api_base="http://test-anonymizer/",
|
||||
mock_testing=False,
|
||||
)
|
||||
|
||||
mock_iterator = _make_mock_session_iterator(
|
||||
json_response=None,
|
||||
status=200,
|
||||
content_type="text/html; charset=utf-8",
|
||||
text_response="Presidio Analyzer service is up.",
|
||||
)
|
||||
|
||||
with patch.object(guardrail, "_get_session_iterator", mock_iterator):
|
||||
results = await guardrail.analyze_text(
|
||||
text="Hello world",
|
||||
presidio_config=None,
|
||||
request_data={},
|
||||
)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_text_http_error_status():
|
||||
"""
|
||||
Test that analyze_text handles 5xx HTTP errors properly.
|
||||
"""
|
||||
guardrail = _OPTIONAL_PresidioPIIMasking(
|
||||
presidio_analyzer_api_base="http://test-analyzer/",
|
||||
presidio_anonymizer_api_base="http://test-anonymizer/",
|
||||
pii_entities_config={"PERSON": PiiAction.BLOCK},
|
||||
mock_testing=False,
|
||||
)
|
||||
|
||||
mock_iterator = _make_mock_session_iterator(
|
||||
json_response=None,
|
||||
status=500,
|
||||
content_type="text/plain",
|
||||
text_response="Internal Server Error",
|
||||
)
|
||||
|
||||
with patch.object(guardrail, "_get_session_iterator", mock_iterator):
|
||||
with pytest.raises(GuardrailRaisedException) as exc_info:
|
||||
await guardrail.analyze_text(
|
||||
text="Hello world",
|
||||
presidio_config=None,
|
||||
request_data={},
|
||||
)
|
||||
assert "HTTP 500" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymize_text_non_json_content_type():
|
||||
"""
|
||||
Test that anonymize_text raises Exception for non-JSON responses.
|
||||
"""
|
||||
guardrail = _OPTIONAL_PresidioPIIMasking(
|
||||
presidio_analyzer_api_base="http://test-analyzer/",
|
||||
presidio_anonymizer_api_base="http://test-anonymizer/",
|
||||
mock_testing=False,
|
||||
)
|
||||
|
||||
mock_iterator = _make_mock_session_iterator(
|
||||
json_response=None,
|
||||
status=200,
|
||||
content_type="text/html",
|
||||
text_response="Presidio Anonymizer service is up.",
|
||||
)
|
||||
|
||||
with patch.object(guardrail, "_get_session_iterator", mock_iterator):
|
||||
with pytest.raises(
|
||||
Exception, match="Presidio anonymizer returned non-JSON Content-Type"
|
||||
):
|
||||
await guardrail.anonymize_text(
|
||||
text="Hello world",
|
||||
analyze_results=[{"start": 0, "end": 5, "entity_type": "PERSON"}],
|
||||
output_parse_pii=False,
|
||||
masked_entity_count={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymize_text_http_error_status():
|
||||
"""
|
||||
Test that anonymize_text raises Exception on HTTP error.
|
||||
"""
|
||||
guardrail = _OPTIONAL_PresidioPIIMasking(
|
||||
presidio_analyzer_api_base="http://test-analyzer/",
|
||||
presidio_anonymizer_api_base="http://test-anonymizer/",
|
||||
mock_testing=False,
|
||||
)
|
||||
|
||||
mock_iterator = _make_mock_session_iterator(
|
||||
json_response=None,
|
||||
status=502,
|
||||
content_type="text/plain",
|
||||
text_response="Bad Gateway",
|
||||
)
|
||||
|
||||
with patch.object(guardrail, "_get_session_iterator", mock_iterator):
|
||||
with pytest.raises(Exception, match="Presidio anonymizer returned HTTP 502"):
|
||||
await guardrail.anonymize_text(
|
||||
text="Hello world",
|
||||
analyze_results=[{"start": 0, "end": 5, "entity_type": "PERSON"}],
|
||||
output_parse_pii=False,
|
||||
masked_entity_count={},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user