Merge pull request #25813 from BerriAI/litellm_ishaan_april15

Litellm ishaan april15
This commit is contained in:
ishaan-berri
2026-04-15 18:29:22 -07:00
committed by GitHub
12 changed files with 877 additions and 52 deletions
@@ -0,0 +1,12 @@
-- CreateIndex (CONCURRENTLY)
--
-- Disclaimer:
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
-- - Do not edit this file after it has been applied to any database: Prisma checksums
-- migrations; add a new migration instead.
-- - Requires PostgreSQL that supports CONCURRENTLY with IF NOT EXISTS (use a new migration
-- without IF NOT EXISTS if you must support older versions).
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_HealthCheckTable_model_id_model_name_checked_at_idx" ON "LiteLLM_HealthCheckTable"("model_id", "model_name", "checked_at" DESC);
@@ -1045,6 +1045,7 @@ model LiteLLM_HealthCheckTable {
@@index([model_name])
@@index([checked_at])
@@index([status])
@@index([model_id, model_name, checked_at(sort: Desc)], map: "LiteLLM_HealthCheckTable_model_id_model_name_checked_at_idx")
}
// Search Tools table for storing search tool configurations
@@ -83,7 +83,12 @@ def _redact_pii_matches(response_json: dict) -> dict:
redacted_response = copy.deepcopy(response_json)
# Get assessments from the response
assessments = redacted_response.get("assessments", [])
# NOTE: We use `.get("key") or []` instead of `.get("key", [])` because
# the Bedrock API can return explicit `null` for list fields (e.g. "regexes": null).
# In Python, dict.get("key", []) returns None (not []) when the key exists
# with a None/null value. The `or []` ensures we always get an iterable,
# preventing "TypeError: 'NoneType' object is not iterable".
assessments = redacted_response.get("assessments") or []
if not assessments:
return redacted_response
@@ -91,13 +96,13 @@ def _redact_pii_matches(response_json: dict) -> dict:
# Redact PII entities in sensitive information policy
sensitive_info_policy = assessment.get("sensitiveInformationPolicy")
if sensitive_info_policy:
pii_entities = sensitive_info_policy.get("piiEntities", [])
pii_entities = sensitive_info_policy.get("piiEntities") or []
for pii_entity in pii_entities:
if "match" in pii_entity:
pii_entity["match"] = "[REDACTED]"
# Redact regex matches
regexes = sensitive_info_policy.get("regexes", [])
regexes = sensitive_info_policy.get("regexes") or []
for regex_match in regexes:
if "match" in regex_match:
regex_match["match"] = "[REDACTED]"
@@ -105,12 +110,12 @@ def _redact_pii_matches(response_json: dict) -> dict:
# Redact custom word matches in word policy
word_policy = assessment.get("wordPolicy")
if word_policy:
custom_words = word_policy.get("customWords", [])
custom_words = word_policy.get("customWords") or []
for custom_word in custom_words:
if "match" in custom_word:
custom_word["match"] = "[REDACTED]"
managed_words = word_policy.get("managedWordLists", [])
managed_words = word_policy.get("managedWordLists") or []
for managed_word in managed_words:
if "match" in managed_word:
managed_word["match"] = "[REDACTED]"
@@ -825,7 +830,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
return False
# Check assessments to determine if any actions were BLOCKED (vs ANONYMIZED)
assessments = response.get("assessments", [])
# NOTE: Use `or []` instead of default param to handle explicit null from Bedrock API.
# See _redact_pii_matches() for detailed explanation of the null safety pattern.
assessments = response.get("assessments") or []
if not assessments:
return False
@@ -833,7 +840,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Check topic policy
topic_policy = assessment.get("topicPolicy")
if topic_policy:
topics = topic_policy.get("topics", [])
topics = topic_policy.get("topics") or []
for topic in topics:
if topic.get("action") == "BLOCKED":
return True
@@ -841,7 +848,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Check content policy
content_policy = assessment.get("contentPolicy")
if content_policy:
filters = content_policy.get("filters", [])
filters = content_policy.get("filters") or []
for filter_item in filters:
if filter_item.get("action") == "BLOCKED":
return True
@@ -849,11 +856,11 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Check word policy
word_policy = assessment.get("wordPolicy")
if word_policy:
custom_words = word_policy.get("customWords", [])
custom_words = word_policy.get("customWords") or []
for custom_word in custom_words:
if custom_word.get("action") == "BLOCKED":
return True
managed_words = word_policy.get("managedWordLists", [])
managed_words = word_policy.get("managedWordLists") or []
for managed_word in managed_words:
if managed_word.get("action") == "BLOCKED":
return True
@@ -861,12 +868,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Check sensitive information policy
sensitive_info_policy = assessment.get("sensitiveInformationPolicy")
if sensitive_info_policy:
pii_entities = sensitive_info_policy.get("piiEntities", [])
pii_entities = sensitive_info_policy.get("piiEntities") or []
if pii_entities:
for pii_entity in pii_entities:
if pii_entity.get("action") == "BLOCKED":
return True
regexes = sensitive_info_policy.get("regexes", [])
regexes = sensitive_info_policy.get("regexes") or []
if regexes:
for regex in regexes:
if regex.get("action") == "BLOCKED":
@@ -875,7 +882,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Check contextual grounding policy
contextual_grounding_policy = assessment.get("contextualGroundingPolicy")
if contextual_grounding_policy:
grounding_filters = contextual_grounding_policy.get("filters", [])
grounding_filters = contextual_grounding_policy.get("filters") or []
for grounding_filter in grounding_filters:
if grounding_filter.get("action") == "BLOCKED":
return True
@@ -1534,7 +1541,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
Raises:
Exception: If content is blocked by Bedrock guardrail
"""
texts = inputs.get("texts", [])
# NOTE: Use `or []` to handle case where inputs["texts"] is explicitly None.
# dict.get("texts", []) would return None if the key exists with a None value.
texts = inputs.get("texts") or []
try:
verbose_proxy_logger.debug(
f"Bedrock Guardrail: Applying guardrail to {len(texts)} text(s)"
@@ -84,7 +84,7 @@ class SharedHealthCheckManager:
"Pod %s failed to acquire health check lock", self.pod_id
)
return acquired
return bool(acquired)
except Exception as e:
verbose_proxy_logger.error("Error acquiring health check lock: %s", str(e))
return False
+1
View File
@@ -1045,6 +1045,7 @@ model LiteLLM_HealthCheckTable {
@@index([model_name])
@@index([checked_at])
@@index([status])
@@index([model_id, model_name, checked_at(sort: Desc)], map: "LiteLLM_HealthCheckTable_model_id_model_name_checked_at_idx")
}
// Search Tools table for storing search tool configurations
+11 -20
View File
@@ -4525,29 +4525,20 @@ class PrismaClient:
async def get_all_latest_health_checks(self):
"""
Get the latest health check for each model
Get the latest health check for each model.
Uses DB-level DISTINCT ON (model_id, model_name) with ORDER BY checked_at DESC
(via Prisma ``distinct`` + ``order``) so we never load the full history into memory.
"""
try:
# Get all unique model names first
all_checks = await self.db.litellm_healthchecktable.find_many(
order={"checked_at": "desc"}
return await self.db.litellm_healthchecktable.find_many(
distinct=["model_id", "model_name"],
order=[
{"model_id": "asc"},
{"model_name": "asc"},
{"checked_at": "desc"},
],
)
# Group by model_name and get the latest for each
latest_checks = {}
for check in all_checks:
# Create a unique key: prefer model_id if available, otherwise use model_name
# This ensures we get the latest check for each unique model
if check.model_id:
key = (check.model_id, check.model_name)
else:
key = (None, check.model_name)
# Only add if we haven't seen this key yet (since checks are ordered by checked_at desc)
if key not in latest_checks:
latest_checks[key] = check
return list(latest_checks.values())
except Exception as e:
verbose_proxy_logger.error(f"Error getting all latest health checks: {e}")
return []
+1
View File
@@ -1045,6 +1045,7 @@ model LiteLLM_HealthCheckTable {
@@index([model_name])
@@index([checked_at])
@@index([status])
@@index([model_id, model_name, checked_at(sort: Desc)], map: "LiteLLM_HealthCheckTable_model_id_model_name_checked_at_idx")
}
// Search Tools table for storing search tool configurations
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
Bench LiteLLM_HealthCheckTable + PrismaClient
- set DATABASE_URL to your Postgres
- Run ```prisma generate``` to install prisma client before running test )
- This test writes to the default "public" database. Make sure to run cleanup after testing
"""
from __future__ import annotations
import argparse
import asyncio
import gc
import os
import sys
import time
import tracemalloc
from datetime import datetime, timedelta, timezone
from typing import Any, List
SEED_MARKER = "benchmark_get_all_latest_health_checks.py" # Utility Marker for cleanup process.
def _rss_kb_linux() -> int:
try:
with open("/proc/self/status", encoding="utf-8") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1])
except OSError:
pass
return 0
def _fmt_kb(kb: int) -> str:
if kb <= 0:
return "n/a"
return f"{kb} KiB (~{kb / 1024.0:.1f} MiB)"
def _build_batch(
*,
batch_index: int,
batch_size: int,
num_models: int,
base_time: datetime,
) -> List[dict[str, Any]]:
rows: List[dict[str, Any]] = []
for i in range(batch_size):
global_i = batch_index * batch_size + i
model_idx = global_i % max(num_models, 1)
model_name = f"bench-model-{model_idx}"
model_id = f"bench-mid-{model_idx}" if model_idx % 2 == 0 else None
checked_at = base_time - timedelta(seconds=global_i)
rows.append(
{
"model_name": model_name,
"model_id": model_id,
"status": "healthy" if global_i % 3 else "unhealthy",
"healthy_count": 1,
"unhealthy_count": 0,
"checked_by": SEED_MARKER,
"checked_at": checked_at,
}
)
return rows
async def _seed(
prisma: Any,
*,
total_rows: int,
batch_size: int,
num_models: int,
) -> None:
db = prisma.db
base_time = datetime.now(timezone.utc)
inserted = 0
batch_idx = 0
while inserted < total_rows:
n = min(batch_size, total_rows - inserted)
await db.litellm_healthchecktable.create_many(
data=_build_batch(
batch_index=batch_idx,
batch_size=n,
num_models=num_models,
base_time=base_time,
)
)
inserted += n
batch_idx += 1
if batch_idx % 10 == 0:
print(f" {inserted}/{total_rows}", flush=True)
print(f"Seeded {inserted} rows ({SEED_MARKER}).")
async def _cleanup(prisma: Any) -> None:
result = await prisma.db.litellm_healthchecktable.delete_many(
where={"checked_by": SEED_MARKER},
)
n = getattr(result, "count", result)
print(f"Deleted {n} rows.")
async def _bench(prisma: Any) -> None:
gc.collect()
rss0 = _rss_kb_linux()
print(f"RSS (after gc): {_fmt_kb(rss0)}")
tracemalloc.start()
t0 = time.perf_counter()
try:
rows = await prisma.get_all_latest_health_checks()
finally:
elapsed = time.perf_counter() - t0
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
gc.collect()
rss1 = _rss_kb_linux()
print(f"get_all_latest_health_checks: {len(rows)} rows in {elapsed:.2f}s")
print(f"tracemalloc peak: {peak / 1e6:.2f} MiB")
print(f"RSS after: {_fmt_kb(rss1)}")
async def _amain() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("action", choices=("seed", "bench", "cleanup"))
p.add_argument("--rows", type=int, default=10_000)
p.add_argument("--batch-size", type=int, default=1000)
p.add_argument("--num-models", type=int, default=50)
args = p.parse_args()
database_url = os.getenv("DATABASE_URL")
if not database_url:
print("Set DATABASE_URL.", file=sys.stderr)
return 1
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if repo_root not in sys.path:
sys.path.insert(0, repo_root)
from litellm.caching.caching import DualCache
from litellm.proxy.proxy_cli import append_query_params
from litellm.proxy.utils import PrismaClient, ProxyLogging
db_url = append_query_params(
database_url, {"connection_limit": 100, "pool_timeout": 60}
)
prisma = PrismaClient(
database_url=db_url,
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
)
try:
await prisma.connect()
except Exception as e:
print(f"Connect failed: {e}", file=sys.stderr)
return 1
try:
if args.action == "seed":
await _seed(
prisma,
total_rows=args.rows,
batch_size=args.batch_size,
num_models=args.num_models,
)
elif args.action == "bench":
await _bench(prisma)
else:
await _cleanup(prisma)
finally:
try:
await prisma.disconnect()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(_amain()))
@@ -5,7 +5,10 @@ import pytest
sys.path.insert(0, os.path.abspath("../.."))
import litellm
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
_redact_pii_matches,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.caching import DualCache
from unittest.mock import MagicMock, AsyncMock, patch
@@ -1601,3 +1604,128 @@ async def test_bedrock_guardrail_post_call_success_hook_no_output_text():
# If no error is raised and result is None, then the test passes
assert result is None
print("✅ No output text in response test passed")
@pytest.mark.asyncio
async def test__redact_pii_matches_null_list_fields():
"""Test that explicit null values from Bedrock API are handled correctly.
The Bedrock API can return explicit JSON null for list fields like
piiEntities, regexes, customWords, managedWordLists. This would cause
TypeError: 'NoneType' object is not iterable if not handled.
"""
# Test 1: null piiEntities and regexes
response_with_null_pii = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None,
"regexes": None,
}
}
],
}
redacted = _redact_pii_matches(response_with_null_pii)
assert redacted is not None
assert redacted["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"] is None
assert redacted["assessments"][0]["sensitiveInformationPolicy"]["regexes"] is None
# Test 2: null customWords and managedWordLists
response_with_null_words = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"wordPolicy": {
"customWords": None,
"managedWordLists": None,
}
}
],
}
redacted = _redact_pii_matches(response_with_null_words)
assert redacted is not None
assert redacted["assessments"][0]["wordPolicy"]["customWords"] is None
assert redacted["assessments"][0]["wordPolicy"]["managedWordLists"] is None
# Test 3: null assessments at top level
response_with_null_assessments = {
"action": "GUARDRAIL_INTERVENED",
"assessments": None,
}
redacted = _redact_pii_matches(response_with_null_assessments)
assert redacted is not None
@pytest.mark.asyncio
async def test__redact_pii_matches_malformed_response():
"""Test _redact_pii_matches with malformed response (should not crash)"""
# Test with completely malformed response
malformed_response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": "not_a_list",
}
redacted_response = _redact_pii_matches(malformed_response)
assert redacted_response == malformed_response
# Test with missing keys
missing_keys_response = {
"action": "GUARDRAIL_INTERVENED",
}
redacted_response = _redact_pii_matches(missing_keys_response)
assert redacted_response == missing_keys_response
@pytest.mark.asyncio
async def test_should_raise_guardrail_blocked_exception_null_fields():
"""Test that _should_raise_guardrail_blocked_exception handles null list fields.
Validates the or [] null-safety pattern works for all policy fields
in _should_raise_guardrail_blocked_exception.
"""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
# Test with null assessments
response_null_assessments = {
"action": "GUARDRAIL_INTERVENED",
"assessments": None,
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_assessments) is False
# Test with null topics in topicPolicy
response_null_topics = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"topicPolicy": {"topics": None}}],
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_topics) is False
# Test with null filters in contentPolicy
response_null_filters = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"contentPolicy": {"filters": None}}],
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_filters) is False
# Test with null customWords and managedWordLists in wordPolicy
response_null_words = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"wordPolicy": {"customWords": None, "managedWordLists": None}}],
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_words) is False
# Test with null piiEntities and regexes in sensitiveInformationPolicy
response_null_pii = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"sensitiveInformationPolicy": {"piiEntities": None, "regexes": None}}],
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_pii) is False
# Test with null filters in contextualGroundingPolicy
response_null_grounding = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [{"contextualGroundingPolicy": {"filters": None}}],
}
assert guardrail._should_raise_guardrail_blocked_exception(response_null_grounding) is False
@@ -1190,6 +1190,483 @@ async def test_bedrock_guardrail_blocked_content_with_masking_enabled():
print("✅ BLOCKED content with masking enabled raises exception correctly")
# ──────────────────────────────────────────────────────────────────────────────
# Null-safety tests for Bedrock guardrail responses
#
# The Bedrock ApplyGuardrail API can return explicit null/None for list fields
# such as "regexes", "piiEntities", "topics", "filters", "customWords", and
# "managedWordLists" when a particular policy category is present in the
# assessment but has no matches.
#
# Python's dict.get("key", []) returns None (NOT []) when the key exists with
# a None value. The `or []` fallback ensures we always iterate over a list.
#
# Without the fix, iterating over None raises:
# TypeError: 'NoneType' object is not iterable
# which surfaces to callers as:
# openai.InternalServerError: Error code: 500
# {'error': {'message': "Bedrock guardrail failed: 'NoneType' object is not iterable", ...}}
# ──────────────────────────────────────────────────────────────────────────────
class TestRedactPiiMatchesNullSafety:
"""Tests for _redact_pii_matches handling of null/None list fields from Bedrock API."""
@pytest.mark.asyncio
async def test_should_handle_null_regexes_in_sensitive_info_policy(self):
"""Bedrock can return regexes: null while piiEntities has data.
Real-world scenario: guardrail detects PII (e.g. EMAIL) but has no
custom regex patterns configured, so the API returns regexes: null.
"""
response = {
"action": "NONE",
"actionReason": "No action.",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": [
{
"action": "NONE",
"detected": True,
"match": "joebloggs@gmail.com",
"type": "EMAIL",
}
],
"regexes": None, # Explicit null from Bedrock API
},
}
],
}
# Should not raise TypeError: 'NoneType' object is not iterable
redacted = _redact_pii_matches(response)
# PII match should be redacted
pii = redacted["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"]
assert pii[0]["match"] == "[REDACTED]"
assert pii[0]["type"] == "EMAIL"
@pytest.mark.asyncio
async def test_should_handle_null_pii_entities_in_sensitive_info_policy(self):
"""Bedrock can return piiEntities: null while regexes has data."""
response = {
"action": "NONE",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None, # null from Bedrock API
"regexes": [
{
"name": "CUSTOM_PATTERN",
"match": "secret-abc-123",
"action": "BLOCKED",
}
],
},
}
],
}
redacted = _redact_pii_matches(response)
regexes = redacted["assessments"][0]["sensitiveInformationPolicy"]["regexes"]
assert regexes[0]["match"] == "[REDACTED]"
@pytest.mark.asyncio
async def test_should_handle_null_custom_words_and_managed_words(self):
"""Bedrock can return null for customWords and managedWordLists in wordPolicy."""
response = {
"action": "NONE",
"assessments": [
{
"wordPolicy": {
"customWords": None, # null from Bedrock API
"managedWordLists": None, # null from Bedrock API
},
}
],
}
# Should not raise TypeError
redacted = _redact_pii_matches(response)
# Values should remain None (no crash)
assert redacted["assessments"][0]["wordPolicy"]["customWords"] is None
assert redacted["assessments"][0]["wordPolicy"]["managedWordLists"] is None
@pytest.mark.asyncio
async def test_should_handle_null_assessments_list(self):
"""Bedrock can return assessments: null."""
response = {
"action": "NONE",
"assessments": None, # null from Bedrock API
}
# Should not raise TypeError
redacted = _redact_pii_matches(response)
assert redacted["assessments"] is None
@pytest.mark.asyncio
async def test_should_handle_all_null_policy_sub_lists_together(self):
"""All sub-list fields are null at the same time — worst-case scenario."""
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None,
"regexes": None,
},
"wordPolicy": {
"customWords": None,
"managedWordLists": None,
},
"topicPolicy": None,
"contentPolicy": None,
"contextualGroundingPolicy": None,
}
],
}
# Should not raise any exception
redacted = _redact_pii_matches(response)
assert redacted is not None
class TestShouldRaiseGuardrailBlockedExceptionNullSafety:
"""Tests for _should_raise_guardrail_blocked_exception handling of null list fields."""
def _create_guardrail(self) -> BedrockGuardrail:
return BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
@pytest.mark.asyncio
async def test_should_handle_all_null_policy_sub_lists(self):
"""All policy sub-lists are null — should not crash, should return False."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": None, # null from Bedrock API
},
"contentPolicy": {
"filters": None, # null
},
"wordPolicy": {
"customWords": None, # null
"managedWordLists": None, # null
},
"sensitiveInformationPolicy": {
"piiEntities": None, # null
"regexes": None, # null
},
"contextualGroundingPolicy": {
"filters": None, # null
},
}
],
}
# No BLOCKED actions found (all lists null) → should return False
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is False
@pytest.mark.asyncio
async def test_should_detect_blocked_despite_other_null_lists(self):
"""A mix of null lists and a real BLOCKED action — should still detect it."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": None, # null — should not crash
},
"contentPolicy": {
"filters": [
{
"type": "HATE",
"confidence": "HIGH",
"action": "BLOCKED",
}
],
},
"wordPolicy": {
"customWords": None, # null
"managedWordLists": None, # null
},
"sensitiveInformationPolicy": {
"piiEntities": None, # null
"regexes": None, # null
},
"contextualGroundingPolicy": None, # entire policy is null
}
],
}
# Should return True because contentPolicy has a BLOCKED filter
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is True
@pytest.mark.asyncio
async def test_should_handle_null_assessments_list(self):
"""assessments itself is null — should return False."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": None, # null from Bedrock API
}
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is False
@pytest.mark.asyncio
async def test_should_handle_null_topics_with_blocked_word_policy(self):
"""topics is null but wordPolicy has a BLOCKED customWord."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": None,
},
"wordPolicy": {
"customWords": [
{"match": "badword", "action": "BLOCKED"}
],
"managedWordLists": None,
},
}
],
}
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is True
@pytest.mark.asyncio
async def test_should_handle_null_pii_with_blocked_regex(self):
"""piiEntities is null but regexes has a BLOCKED match."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None,
"regexes": [
{"name": "SSN", "match": "123-45-6789", "action": "BLOCKED"}
],
},
}
],
}
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is True
@pytest.mark.asyncio
async def test_should_handle_null_grounding_filters(self):
"""contextualGroundingPolicy.filters is null — should not crash."""
guardrail = self._create_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"contextualGroundingPolicy": {
"filters": None,
},
}
],
}
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is False
@pytest.mark.asyncio
async def test_should_not_crash_when_action_is_not_intervened(self):
"""If action != GUARDRAIL_INTERVENED, null lists should never be reached."""
guardrail = self._create_guardrail()
response = {
"action": "NONE",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None,
"regexes": None,
},
}
],
}
result = guardrail._should_raise_guardrail_blocked_exception(response)
assert result is False
class TestApplyGuardrailNullSafety:
"""Tests for apply_guardrail handling of null/None texts input."""
@pytest.mark.asyncio
async def test_should_handle_none_texts_in_inputs(self):
"""inputs[\"texts\"] is explicitly None — should not crash."""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
inputs = {"texts": None} # Explicit None
mock_credentials = MagicMock()
with patch.object(
guardrail.async_handler, "post", new_callable=AsyncMock
) as mock_post, patch.object(
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
), patch.object(
guardrail, "_prepare_request", return_value=MagicMock()
):
# With empty texts (from None → []), no Bedrock API call should be made
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="request",
)
# Should return empty texts without crashing
assert result.get("texts") == []
# No Bedrock API call should be made for empty input
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_should_handle_missing_texts_key(self):
"""inputs has no \"texts\" key at all — should not crash."""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
inputs = {} # No "texts" key
mock_credentials = MagicMock()
with patch.object(
guardrail.async_handler, "post", new_callable=AsyncMock
) as mock_post, patch.object(
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
), patch.object(
guardrail, "_prepare_request", return_value=MagicMock()
):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="request",
)
assert result.get("texts") == []
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_bedrock_guardrail_blocked_vs_anonymized_actions():
"""Test that BLOCKED actions raise exceptions but ANONYMIZED actions do not"""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
# Test 1: ANONYMIZED action should NOT raise exception
anonymized_response = {
"action": "GUARDRAIL_INTERVENED",
"outputs": [{"text": "Hello, my phone number is {PHONE}"}],
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": [
{
"type": "PHONE",
"match": "+1 412 555 1212",
"action": "ANONYMIZED",
}
]
}
}
],
}
should_raise = guardrail._should_raise_guardrail_blocked_exception(
anonymized_response
)
assert should_raise is False, "ANONYMIZED actions should not raise exceptions"
# Test 2: BLOCKED action should raise exception
blocked_response = {
"action": "GUARDRAIL_INTERVENED",
"outputs": [{"text": "I can't provide that information."}],
"assessments": [
{
"topicPolicy": {
"topics": [
{"name": "Sensitive Topic", "type": "DENY", "action": "BLOCKED"}
]
}
}
],
}
should_raise = guardrail._should_raise_guardrail_blocked_exception(blocked_response)
assert should_raise is True, "BLOCKED actions should raise exceptions"
# Test 3: Mixed actions - should raise if ANY action is BLOCKED
mixed_response = {
"action": "GUARDRAIL_INTERVENED",
"outputs": [{"text": "I can't provide that information."}],
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": [
{
"type": "PHONE",
"match": "+1 412 555 1212",
"action": "ANONYMIZED",
}
]
},
"topicPolicy": {
"topics": [
{"name": "Blocked Topic", "type": "DENY", "action": "BLOCKED"}
]
},
}
],
}
should_raise = guardrail._should_raise_guardrail_blocked_exception(mixed_response)
assert (
should_raise is True
), "Mixed actions with any BLOCKED should raise exceptions"
# Test 4: NONE action should not raise exception
none_response = {
"action": "NONE",
"outputs": [],
"assessments": [],
}
should_raise = guardrail._should_raise_guardrail_blocked_exception(none_response)
assert should_raise is False, "NONE action should not raise exceptions"
print("\u2705 BLOCKED vs ANONYMIZED actions test passed")
# ---------------------------------------------------------------------------
# L3: _extract_blocked_assessments + _get_http_exception_for_blocked_guardrail
# Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error.
@@ -1338,4 +1815,3 @@ def test_get_http_exception_no_blocked_assessments_omits_field():
assert isinstance(exc, HTTPException)
assert "assessments" not in exc.detail
assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r"
@@ -406,12 +406,6 @@ async def test_save_background_health_checks_to_db_exception_handling():
@pytest.mark.asyncio
async def test_get_all_latest_health_checks_with_model_id(mock_prisma):
"""Test get_all_latest_health_checks properly groups by model_id"""
# Create mock checks with same model_name but different model_id
mock_check1 = MagicMock()
mock_check1.model_id = "model-123"
mock_check1.model_name = "gpt-3.5-turbo"
mock_check1.checked_at = datetime.now(timezone.utc) - timedelta(minutes=10)
mock_check2 = MagicMock()
mock_check2.model_id = "model-456"
mock_check2.model_name = "gpt-3.5-turbo"
@@ -424,7 +418,7 @@ async def test_get_all_latest_health_checks_with_model_id(mock_prisma):
# Order by checked_at desc
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(
return_value=[mock_check3, mock_check2, mock_check1]
return_value=[mock_check3, mock_check2]
)
result = await mock_prisma.get_all_latest_health_checks()
@@ -445,18 +439,13 @@ async def test_get_all_latest_health_checks_with_model_id(mock_prisma):
@pytest.mark.asyncio
async def test_get_all_latest_health_checks_without_model_id(mock_prisma):
"""Test get_all_latest_health_checks groups by model_name when model_id is None"""
mock_check1 = MagicMock()
mock_check1.model_id = None
mock_check1.model_name = "gpt-3.5-turbo"
mock_check1.checked_at = datetime.now(timezone.utc) - timedelta(minutes=10)
mock_check2 = MagicMock()
mock_check2.model_id = None
mock_check2.model_name = "gpt-3.5-turbo"
mock_check2.checked_at = datetime.now(timezone.utc) - timedelta(minutes=1) # Latest
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(
return_value=[mock_check2, mock_check1]
return_value=[mock_check2]
)
result = await mock_prisma.get_all_latest_health_checks()
@@ -467,6 +456,41 @@ async def test_get_all_latest_health_checks_without_model_id(mock_prisma):
assert result[0].checked_at == mock_check2.checked_at # Latest
@pytest.mark.asyncio
async def test_get_all_latest_health_checks_same_name_with_and_without_model_id(mock_prisma):
"""
Same model_name can appear twice after DISTINCT ON: once keyed by (model_id, name)
and once by (NULL, name) different Postgres groups than a single row with id.
"""
now = datetime.now(timezone.utc)
with_id = MagicMock()
with_id.model_id = "deployment-abc"
with_id.model_name = "gpt-4"
with_id.checked_at = now - timedelta(minutes=2)
without_id = MagicMock()
without_id.model_id = None
without_id.model_name = "gpt-4"
without_id.checked_at = now - timedelta(minutes=1)
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(
return_value=[without_id, with_id]
)
result = await mock_prisma.get_all_latest_health_checks()
assert len(result) == 2
names = {r.model_name for r in result}
assert names == {"gpt-4"}
ids = {r.model_id for r in result}
assert "deployment-abc" in ids
assert None in ids
by_key = {(r.model_id, r.model_name): r for r in result}
assert by_key[("deployment-abc", "gpt-4")].checked_at == with_id.checked_at
assert by_key[(None, "gpt-4")].checked_at == without_id.checked_at
@pytest.mark.asyncio
async def test_perform_health_check_and_save_passes_model_id_to_perform_health_check():
"""Test that _perform_health_check_and_save passes model_id to perform_health_check so health checks run by model id."""
Generated
+2 -2
View File
@@ -11,7 +11,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-08T16:01:27.663665Z"
exclude-newer = "2026-04-13T01:16:14.00322Z"
exclude-newer-span = "P3D"
[manifest]
@@ -3602,7 +3602,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.83.6"
version = "1.83.8"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },