litellm/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py
yuneng-jiang 6a0d03914c
test: drop the cwd-relative sys.path.insert calls from the test suite (#37802)
* test: drop the cwd-relative sys.path.insert calls from the test suite

TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.

Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.

Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.

* test: drop the duplicate imports the sys.path sweep exposed to F811

* test(pre-call-utils): restore the os import the new bedrock tests need
2026-08-22 09:25:58 -07:00

2596 lines
95 KiB
Python

import json
from datetime import datetime
from typing import Dict, List, Optional
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_endpoints import (
CreateGuardrailRequest,
PatchGuardrailRequest,
RegisterGuardrailRequest,
UpdateGuardrailRequest,
apply_guardrail,
approve_guardrail_submission,
create_guardrail,
delete_guardrail,
get_guardrail_info,
get_guardrail_submission,
get_guardrail_ui_settings,
list_guardrail_submissions,
list_guardrails_v2,
patch_guardrail,
register_guardrail,
reject_guardrail_submission,
update_guardrail,
)
MOCK_ADMIN_USER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
from litellm.proxy.guardrails.guardrail_registry import (
IN_MEMORY_GUARDRAIL_HANDLER,
InMemoryGuardrailHandler,
)
from litellm.types.guardrails import (
ApplyGuardrailRequest,
BaseLitellmParams,
Guardrail,
GuardrailInfoResponse,
LitellmParams,
)
# Mock data for testing
MOCK_DB_GUARDRAIL = {
"guardrail_id": "test-db-guardrail",
"guardrail_name": "Test DB Guardrail",
"litellm_params": {
"guardrail": "test.guardrail",
"mode": "pre_call",
},
"guardrail_info": {"description": "Test guardrail from DB"},
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
MOCK_CONFIG_GUARDRAIL = {
"guardrail_id": "test-config-guardrail",
"guardrail_name": "Test Config Guardrail",
"litellm_params": {
"guardrail": "custom_guardrail.myCustomGuardrail",
"mode": "during_call",
},
"guardrail_info": {"description": "Test guardrail from config"},
}
MOCK_GUARDRAIL = Guardrail(
guardrail_name=MOCK_CONFIG_GUARDRAIL["guardrail_name"],
litellm_params=LitellmParams(**MOCK_CONFIG_GUARDRAIL["litellm_params"]),
guardrail_info=MOCK_CONFIG_GUARDRAIL["guardrail_info"],
)
MOCK_CREATE_REQUEST = CreateGuardrailRequest(guardrail=MOCK_GUARDRAIL)
MOCK_UPDATE_REQUEST = UpdateGuardrailRequest(guardrail=MOCK_GUARDRAIL)
MOCK_PATCH_REQUEST = PatchGuardrailRequest(
guardrail_name="Updated Test Guardrail",
litellm_params={"guardrail": "updated.guardrail", "mode": "post_call"},
guardrail_info={"description": "Updated test guardrail"},
)
@pytest.fixture
def mock_prisma_client(mocker):
"""Mock Prisma client for testing"""
mock_client = mocker.Mock()
# Create async mocks for the database methods
mock_client.db = mocker.Mock()
mock_client.db.litellm_guardrailstable = mocker.Mock()
mock_client.db.litellm_guardrailstable.find_many = AsyncMock(
return_value=[MOCK_DB_GUARDRAIL]
)
mock_client.db.litellm_guardrailstable.find_unique = AsyncMock(
return_value=MOCK_DB_GUARDRAIL
)
return mock_client
@pytest.fixture
def mock_in_memory_handler(mocker):
"""Mock InMemoryGuardrailHandler for testing"""
mock_handler = mocker.Mock(spec=InMemoryGuardrailHandler)
mock_handler.list_in_memory_guardrails.return_value = [MOCK_CONFIG_GUARDRAIL]
mock_handler.get_guardrail_by_id.return_value = MOCK_CONFIG_GUARDRAIL
mock_handler.get_source.return_value = "config"
mock_handler.initialize_guardrail = mocker.Mock()
mock_handler.update_in_memory_guardrail = mocker.Mock()
mock_handler.delete_in_memory_guardrail = mocker.Mock()
mock_handler.reconcile_db_guardrails = mocker.Mock(return_value=[])
return mock_handler
@pytest.fixture
def mock_guardrail_registry(mocker):
"""Mock GuardrailRegistry for testing"""
mock_registry = mocker.Mock()
mock_registry.add_guardrail_to_db = AsyncMock(
return_value={**MOCK_DB_GUARDRAIL, "guardrail_id": "new-test-guardrail-id"}
)
mock_registry.delete_guardrail_from_db = AsyncMock(return_value=MOCK_DB_GUARDRAIL)
mock_registry.get_guardrail_by_id_from_db = AsyncMock(
return_value=MOCK_DB_GUARDRAIL
)
mock_registry.update_guardrail_in_db = AsyncMock(return_value=MOCK_DB_GUARDRAIL)
return mock_registry
@pytest.mark.asyncio
async def test_list_guardrails_v2_with_db_and_config(
mocker, mock_prisma_client, mock_in_memory_handler
):
"""Test listing guardrails from both DB and config"""
# Mock the prisma client
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Mock the in-memory handler
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
response = await list_guardrails_v2(user_api_key_dict=admin_auth)
assert len(response.guardrails) == 2
# Check DB guardrail
db_guardrail = next(
g for g in response.guardrails if g.guardrail_id == "test-db-guardrail"
)
assert db_guardrail.guardrail_name == "Test DB Guardrail"
assert db_guardrail.guardrail_definition_location == "db"
assert isinstance(db_guardrail.litellm_params, BaseLitellmParams)
# Check config guardrail
config_guardrail = next(
g for g in response.guardrails if g.guardrail_id == "test-config-guardrail"
)
assert config_guardrail.guardrail_name == "Test Config Guardrail"
assert config_guardrail.guardrail_definition_location == "config"
assert isinstance(config_guardrail.litellm_params, BaseLitellmParams)
@pytest.mark.asyncio
async def test_list_guardrails_v2_skips_stale_db_backed_in_memory_entries(mocker):
"""
A guardrail that's still in this pod's memory tagged source='db' but is no
longer in the DB result (deleted on another pod, awaiting reconcile) must
NOT surface in the list response — pre-fix it leaked as 'config'.
"""
stale_guardrail = {
"guardrail_id": "stale-db-id",
"guardrail_name": "Stale DB Guardrail",
"litellm_params": {"guardrail": "bedrock", "mode": "pre_call"},
"guardrail_info": {},
}
mock_prisma_client = mocker.Mock()
mock_prisma_client.db = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[])
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.list_in_memory_guardrails.return_value = [stale_guardrail]
mock_in_memory_handler.get_source.return_value = "db"
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
response = await list_guardrails_v2(user_api_key_dict=admin_auth)
assert response.guardrails == []
mock_in_memory_handler.get_source.assert_called_with("stale-db-id")
@pytest.mark.asyncio
async def test_get_guardrail_info_404s_stale_db_backed_entry(
mocker, mock_prisma_client, mock_in_memory_handler
):
"""
Stale DB-backed entry (in-memory but not in DB) must 404 instead of being
returned as if it were a config-loaded guardrail.
"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
mock_prisma_client.db.litellm_guardrailstable.find_unique = AsyncMock(
return_value=None
)
# In-memory still has it, but it's tagged as 'db' (stale, awaiting reconcile)
mock_in_memory_handler.get_source.return_value = "db"
with pytest.raises(HTTPException) as exc_info:
await get_guardrail_info("stale-db-id")
assert exc_info.value.status_code == 404
assert "not found" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_list_guardrails_v2_masks_sensitive_data_in_db_guardrails(mocker):
"""Test that sensitive litellm_params are masked for DB guardrails in list response"""
db_guardrail_with_secrets = {
"guardrail_id": "secret-db-guardrail",
"guardrail_name": "DB Guardrail with Secrets",
"litellm_params": {
"guardrail": "azure/text_moderations",
"mode": "pre_call",
"api_key": "sk-1234567890abcdef",
"api_base": "https://api.secret.example.com",
},
"guardrail_info": {"description": "Test guardrail"},
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
mock_prisma_client = mocker.Mock()
mock_prisma_client.db = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(
return_value=[db_guardrail_with_secrets]
)
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.list_in_memory_guardrails.return_value = []
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
response = await list_guardrails_v2(user_api_key_dict=admin_auth)
assert len(response.guardrails) == 1
guardrail = response.guardrails[0]
litellm_params = guardrail.litellm_params
if isinstance(litellm_params, dict):
params = litellm_params
else:
params = (
litellm_params.model_dump()
if hasattr(litellm_params, "model_dump")
else dict(litellm_params)
)
# Sensitive keys (containing "key", "secret", "token", etc.) should be masked
assert params["api_key"] != "sk-1234567890abcdef"
assert "****" in str(params["api_key"])
# Non-sensitive keys should remain unchanged
assert params["guardrail"] == "azure/text_moderations"
assert params["mode"] == "pre_call"
assert params["api_base"] == "https://api.secret.example.com"
@pytest.mark.asyncio
async def test_list_guardrails_v2_masks_sensitive_data_in_config_guardrails(mocker):
"""Test that sensitive litellm_params are masked for in-memory/config guardrails in list response"""
config_guardrail_with_secrets = {
"guardrail_id": "secret-config-guardrail",
"guardrail_name": "Config Guardrail with Secrets",
"litellm_params": {
"guardrail": "bedrock",
"mode": "during_call",
"api_key": "my-secret-bedrock-key",
"vertex_credentials": "{sensitive_creds}",
},
"guardrail_info": {"description": "Test guardrail from config"},
}
mock_prisma_client = mocker.Mock()
mock_prisma_client.db = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[])
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.list_in_memory_guardrails.return_value = [
config_guardrail_with_secrets
]
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
response = await list_guardrails_v2(user_api_key_dict=admin_auth)
assert len(response.guardrails) == 1
guardrail = response.guardrails[0]
litellm_params = guardrail.litellm_params
if isinstance(litellm_params, dict):
params = litellm_params
else:
params = (
litellm_params.model_dump()
if hasattr(litellm_params, "model_dump")
else dict(litellm_params)
)
# Sensitive keys should be masked
assert params["api_key"] != "my-secret-bedrock-key"
assert "****" in str(params["api_key"])
assert params["vertex_credentials"] != "{sensitive_creds}"
assert "****" in str(params["vertex_credentials"])
# Non-sensitive keys should remain unchanged
assert params["guardrail"] == "bedrock"
assert params["mode"] == "during_call"
@pytest.mark.asyncio
async def test_list_guardrails_v2_admin_viewer_sees_guardrails_of_teams_they_are_not_in(
mocker,
):
"""
proxy_admin_viewer reads the same unscoped list as proxy_admin: a team-owned
guardrail must surface even though the viewer belongs to no teams.
"""
other_team_guardrail = {
"guardrail_id": "other-team-guardrail",
"guardrail_name": "Other Team Guardrail",
"litellm_params": {"guardrail": "bedrock", "mode": "pre_call"},
"guardrail_info": {"description": "owned by a team the viewer is not in"},
"team_id": "team-viewer-is-not-in",
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
mock_prisma_client = mocker.Mock()
mock_prisma_client.db = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(
return_value=[other_team_guardrail]
)
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.list_in_memory_guardrails.return_value = []
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
mock_get_user_team_ids = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=[]),
)
viewer_auth = UserAPIKeyAuth(
user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
)
response = await list_guardrails_v2(user_api_key_dict=viewer_auth)
assert [g.guardrail_id for g in response.guardrails] == ["other-team-guardrail"]
mock_get_user_team_ids.assert_not_called()
@pytest.mark.asyncio
async def test_list_guardrails_v2_masks_sensitive_data_for_admin_viewer(mocker):
"""
Read parity for proxy_admin_viewer must not also hand out unmasked secrets.
The guardrail is team-owned so it only reaches the viewer via the admin path.
"""
other_team_guardrail_with_secrets = {
"guardrail_id": "other-team-secret-guardrail",
"guardrail_name": "Other Team Guardrail with Secrets",
"litellm_params": {
"guardrail": "azure/text_moderations",
"mode": "pre_call",
"api_key": "sk-viewer-must-not-see-this",
},
"guardrail_info": {},
"team_id": "team-viewer-is-not-in",
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
mock_prisma_client = mocker.Mock()
mock_prisma_client.db = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable = mocker.Mock()
mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock(
return_value=[other_team_guardrail_with_secrets]
)
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.list_in_memory_guardrails.return_value = []
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=[]),
)
viewer_auth = UserAPIKeyAuth(
user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
)
response = await list_guardrails_v2(user_api_key_dict=viewer_auth)
guardrail = next(
g
for g in response.guardrails
if g.guardrail_id == "other-team-secret-guardrail"
)
params = guardrail.litellm_params.model_dump()
assert params["api_key"] != "sk-viewer-must-not-see-this"
assert "****" in str(params["api_key"])
assert params["guardrail"] == "azure/text_moderations"
@pytest.mark.asyncio
async def test_get_guardrail_info_from_db(mocker, mock_prisma_client):
"""Test getting guardrail info from DB"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
response = await get_guardrail_info("test-db-guardrail")
assert response.guardrail_id == "test-db-guardrail"
assert response.guardrail_name == "Test DB Guardrail"
assert isinstance(response.litellm_params, BaseLitellmParams)
assert response.guardrail_info == {"description": "Test guardrail from DB"}
@pytest.mark.asyncio
async def test_get_guardrail_info_from_config(
mocker, mock_prisma_client, mock_in_memory_handler
):
"""Test getting guardrail info from config when not found in DB"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
# Mock DB to return None
mock_prisma_client.db.litellm_guardrailstable.find_unique = AsyncMock(
return_value=None
)
response = await get_guardrail_info("test-config-guardrail")
assert response.guardrail_id == "test-config-guardrail"
assert response.guardrail_name == "Test Config Guardrail"
assert isinstance(response.litellm_params, BaseLitellmParams)
assert response.guardrail_info == {"description": "Test guardrail from config"}
@pytest.mark.asyncio
async def test_get_guardrail_info_not_found(
mocker, mock_prisma_client, mock_in_memory_handler
):
"""Test getting guardrail info when not found in either DB or config"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
# Mock both DB and in-memory handler to return None
mock_prisma_client.db.litellm_guardrailstable.find_unique = AsyncMock(
return_value=None
)
mock_in_memory_handler.get_guardrail_by_id.return_value = None
with pytest.raises(HTTPException) as exc_info:
await get_guardrail_info("non-existent-guardrail")
assert exc_info.value.status_code == 404
assert "not found" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_list_guardrails_v2_without_prisma_returns_config_guardrails(
mocker, mock_in_memory_handler
):
"""
A proxy without a DB must still list config-defined guardrails instead of
raising 500 'Prisma client not initialized'.
"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
response = await list_guardrails_v2(user_api_key_dict=MOCK_ADMIN_USER)
assert len(response.guardrails) == 1
config_guardrail = response.guardrails[0]
assert config_guardrail.guardrail_id == "test-config-guardrail"
assert config_guardrail.guardrail_name == "Test Config Guardrail"
assert config_guardrail.guardrail_definition_location == "config"
@pytest.mark.asyncio
async def test_list_guardrails_v2_without_prisma_non_admin_sees_unrestricted_config_guardrails(
mocker, mock_in_memory_handler
):
"""
A non-admin caller on a no-DB proxy must see config guardrails that carry
no team_id restriction; the team lookup must not blow up without a DB.
"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
non_admin_auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal-user-1"
)
response = await list_guardrails_v2(user_api_key_dict=non_admin_auth)
assert [g.guardrail_id for g in response.guardrails] == ["test-config-guardrail"]
@pytest.mark.asyncio
async def test_get_guardrail_info_without_prisma_returns_config_guardrail(
mocker, mock_in_memory_handler
):
"""
The info endpoint must serve config-defined guardrails from the in-memory
registry when no DB is attached instead of raising 500.
"""
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
response = await get_guardrail_info("test-config-guardrail")
assert response.guardrail_id == "test-config-guardrail"
assert response.guardrail_name == "Test Config Guardrail"
assert response.guardrail_definition_location == "config"
@pytest.mark.asyncio
async def test_get_guardrail_info_without_prisma_404s_unknown_id(
mocker, mock_in_memory_handler
):
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
mock_in_memory_handler.get_guardrail_by_id.return_value = None
with pytest.raises(HTTPException) as exc_info:
await get_guardrail_info("non-existent-guardrail")
assert exc_info.value.status_code == 404
def test_get_guardrails_list_response_includes_guardrail_id():
"""
The v1 list response is the UI's fallback when v2 fails; without ids every
row click requests /guardrails/undefined/info.
"""
from litellm.proxy.guardrails.guardrail_endpoints import (
_get_guardrails_list_response,
)
response = _get_guardrails_list_response(
[
{
"guardrail_id": "stable-config-id",
"guardrail_name": "tooling",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
},
}
]
)
assert response.guardrails[0].guardrail_id == "stable-config-id"
def test_get_provider_specific_params():
"""Test getting provider-specific parameters"""
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
from litellm.proxy.guardrails.guardrail_hooks.azure import (
AzureContentSafetyTextModerationGuardrail,
)
config_model = AzureContentSafetyTextModerationGuardrail.get_config_model()
if config_model is None:
pytest.skip("Azure config model not available")
fields = _get_fields_from_model(config_model)
print("FIELDS", fields)
# Test that we get the expected nested structure
assert isinstance(fields, dict)
# Check that we have the expected top-level fields
assert "api_key" in fields
assert "api_base" in fields
assert "api_version" in fields
assert "optional_params" in fields
# Check the structure of a simple field
assert (
fields["api_key"]["description"]
== "API key for the Azure Content Safety Prompt Shield guardrail"
)
assert fields["api_key"]["required"] == False
assert fields["api_key"]["type"] == "string" # Should be string, not None
# Check the structure of the nested optional_params field
assert fields["optional_params"]["type"] == "nested"
assert fields["optional_params"]["required"] == True
assert "fields" in fields["optional_params"]
# Check nested fields within optional_params
nested_fields = fields["optional_params"]["fields"]
assert "severity_threshold" in nested_fields
assert "severity_threshold_by_category" in nested_fields
assert "categories" in nested_fields
assert "blocklistNames" in nested_fields
assert "haltOnBlocklistHit" in nested_fields
assert "outputType" in nested_fields
# Check structure of a nested field
assert (
nested_fields["severity_threshold"]["description"]
== "Severity threshold for the Azure Content Safety Text Moderation guardrail across all categories"
)
assert nested_fields["severity_threshold"]["required"] == False
assert (
nested_fields["severity_threshold"]["type"] == "number"
) # Should be number, not None
# Check other field types
assert nested_fields["categories"]["type"] == "multiselect"
assert nested_fields["blocklistNames"]["type"] == "array"
assert nested_fields["haltOnBlocklistHit"]["type"] == "boolean"
assert (
nested_fields["outputType"]["type"] == "select"
) # Literal type should be select
def test_optional_params_not_returned_when_not_overridden():
"""Test that optional_params is not returned when the config model doesn't override it"""
from typing import Optional
from pydantic import BaseModel, Field
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
class TestGuardrailConfig(GuardrailConfigModel):
api_key: Optional[str] = Field(
default=None,
description="Test API key",
)
api_base: Optional[str] = Field(
default=None,
description="Test API base",
)
@staticmethod
def ui_friendly_name() -> str:
return "Test Guardrail"
# Get fields from the model
fields = _get_fields_from_model(TestGuardrailConfig)
print("FIELDS", fields)
assert "optional_params" not in fields
def test_optional_params_returned_when_properly_overridden():
"""Test that optional_params IS returned when the config model properly overrides it"""
from typing import Optional
from pydantic import BaseModel, Field
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
# Create specific optional params model
class SpecificOptionalParams(BaseModel):
threshold: Optional[float] = Field(
default=0.5, description="Detection threshold"
)
categories: Optional[List[str]] = Field(
default=None, description="Categories to check"
)
# Create a config model that DOES override optional_params with a specific type
class TestGuardrailConfigWithOptionalParams(
GuardrailConfigModel[SpecificOptionalParams]
):
api_key: Optional[str] = Field(
default=None,
description="Test API key",
)
@staticmethod
def ui_friendly_name() -> str:
return "Test Guardrail With Optional Params"
# Get fields from the model
fields = _get_fields_from_model(TestGuardrailConfigWithOptionalParams)
print("FIELDS", fields)
assert "optional_params" in fields
@pytest.mark.asyncio
async def test_bedrock_guardrail_prepare_request_with_api_key():
"""Test _prepare_request method uses Bearer token when api_key is provided in data"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id", guardrailVersion="1"
)
mock_credentials = Mock()
test_data = {"source": "INPUT", "content": [{"text": {"text": "test content"}}]}
prepared_request = guardrail_hook._prepare_request(
credentials=mock_credentials,
data=test_data,
optional_params={},
aws_region_name="us-east-1",
api_key="test-bearer-token-123",
)
# Verify Bearer token is used in Authorization header
assert "Authorization" in prepared_request.headers
assert prepared_request.headers["Authorization"] == "Bearer test-bearer-token-123"
# Verify URL is correct
expected_url = "https://bedrock-runtime.us-east-1.amazonaws.com/guardrail/test-guardrail-id/version/1/apply"
assert prepared_request.url == expected_url
@pytest.mark.asyncio
async def test_bedrock_guardrail_prepare_request_without_api_key():
"""Test _prepare_request method falls back to SigV4 when no api_key is provided"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id", guardrailVersion="1"
)
# Mock credentials
mock_credentials = Mock()
# Test data without api_key
test_data = {"source": "INPUT", "content": [{"text": {"text": "test content"}}]}
with (
patch(
"litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.get_secret_str"
) as mock_get_secret,
patch("botocore.auth.SigV4Auth") as mock_sigv4_auth,
patch("botocore.awsrequest.AWSRequest") as mock_aws_request,
):
# Mock no AWS_BEARER_TOKEN_BEDROCK
mock_get_secret.return_value = None
# Mock SigV4Auth
mock_sigv4_instance = Mock()
mock_sigv4_auth.return_value = mock_sigv4_instance
# Mock AWSRequest
mock_request_instance = Mock()
mock_request_instance.prepare.return_value = Mock()
mock_aws_request.return_value = mock_request_instance
# Call _prepare_request
prepared_request = guardrail_hook._prepare_request(
credentials=mock_credentials,
data=test_data,
optional_params={},
aws_region_name="us-east-1",
)
# Verify SigV4 auth was used
mock_sigv4_auth.assert_called_once_with(
mock_credentials, "bedrock", "us-east-1"
)
mock_sigv4_instance.add_auth.assert_called_once()
@pytest.mark.asyncio
async def test_bedrock_guardrail_prepare_request_with_bearer_token_env():
"""Test _prepare_request method uses Bearer token from environment when available"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id", guardrailVersion="1"
)
# Mock credentials
mock_credentials = Mock()
# Test data without api_key
test_data = {"source": "INPUT", "content": [{"text": {"text": "test content"}}]}
with (
patch(
"litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.get_secret_str"
) as mock_get_secret,
patch("botocore.awsrequest.AWSRequest") as mock_aws_request,
):
mock_get_secret.return_value = "env-bearer-token-456"
mock_request_instance = Mock()
mock_request_instance.prepare.return_value = Mock()
mock_aws_request.return_value = mock_request_instance
prepared_request = guardrail_hook._prepare_request(
credentials=mock_credentials,
data=test_data,
optional_params={},
aws_region_name="us-east-1",
)
# Verify Bearer token from environment is used
mock_aws_request.assert_called_once()
call_args = mock_aws_request.call_args
headers = call_args[1]["headers"]
assert headers["Authorization"] == "Bearer env-bearer-token-456"
@pytest.mark.asyncio
async def test_bedrock_guardrail_make_api_request_passes_api_key():
"""Test make_bedrock_api_request method correctly passes api_key from request_data"""
from unittest.mock import AsyncMock, Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id", guardrailVersion="1"
)
guardrail_hook.async_handler = Mock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"action": "NONE", "outputs": []}
test_request_data = {"api_key": "test-api-key-789"}
with (
patch.object(
guardrail_hook.async_handler, "post", AsyncMock(return_value=mock_response)
),
patch.object(guardrail_hook, "_load_credentials") as mock_load_creds,
patch.object(guardrail_hook, "convert_to_bedrock_format") as mock_convert,
patch.object(
guardrail_hook, "get_guardrail_dynamic_request_body_params"
) as mock_get_params,
patch.object(
guardrail_hook, "add_standard_logging_guardrail_information_to_request_data"
),
patch("botocore.awsrequest.AWSRequest") as mock_aws_request,
):
mock_load_creds.return_value = (Mock(), "us-east-1")
mock_convert.return_value = {"source": "INPUT", "content": [{"text": {"text": "test"}}]}
mock_get_params.return_value = {}
mock_request_instance = Mock()
mock_request_instance.url = "test-url"
mock_request_instance.body = b"test-body"
mock_request_instance.headers = {
"Content-Type": "application/json",
"Authorization": "Bearer test-api-key-789",
}
mock_request_instance.prepare.return_value = Mock()
mock_aws_request.return_value = mock_request_instance
await guardrail_hook.make_bedrock_api_request(
source="INPUT",
messages=[{"role": "user", "content": "test"}],
request_data=test_request_data,
)
# Verify _prepare_request was invoked and used the api_key
mock_aws_request.assert_called_once()
call_args = mock_aws_request.call_args
headers = call_args[1]["headers"]
assert headers["Authorization"] == "Bearer test-api-key-789"
@pytest.mark.parametrize(
"scenario,expected_result,expected_exception",
[
("success_with_sync", "new-test-guardrail-id", None),
("success_sync_fails", "new-test-guardrail-id", None),
("database_failure", None, HTTPException),
("no_prisma_client", None, HTTPException),
],
ids=[
"success_with_immediate_sync",
"success_but_sync_fails",
"database_error",
"missing_prisma_client",
],
)
@pytest.mark.asyncio
async def test_create_guardrail_endpoint(
scenario,
expected_result,
expected_exception,
mocker,
mock_guardrail_registry,
mock_in_memory_handler,
):
"""Test create_guardrail endpoint with different scenarios"""
# Configure mocks based on scenario
mock_logger = None
if scenario == "success_with_sync":
mock_prisma_client = mocker.Mock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "success_sync_fails":
mock_prisma_client = mocker.Mock()
mock_in_memory_handler.initialize_guardrail.side_effect = Exception(
"Sync failed"
)
mock_logger = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.verbose_proxy_logger"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "database_failure":
mock_prisma_client = mocker.Mock()
mock_guardrail_registry.add_guardrail_to_db.side_effect = Exception(
"Database error"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
elif scenario == "no_prisma_client":
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
# Run the test
if expected_exception:
with pytest.raises(expected_exception) as exc_info:
await create_guardrail(
MOCK_CREATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER
)
if scenario == "database_failure":
assert "Database error" in str(exc_info.value.detail)
elif scenario == "no_prisma_client":
assert "Prisma client not initialized" in str(exc_info.value.detail)
else:
result = await create_guardrail(
MOCK_CREATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER
)
assert result["guardrail_id"] == expected_result
assert result["guardrail_name"] == "Test DB Guardrail"
mock_guardrail_registry.add_guardrail_to_db.assert_called_once_with(
guardrail=MOCK_CREATE_REQUEST.guardrail, prisma_client=mocker.ANY
)
mock_in_memory_handler.initialize_guardrail.assert_called_once()
if scenario == "success_sync_fails":
assert mock_logger is not None
mock_logger.warning.assert_called_once()
assert "Failed to initialize guardrail" in str(
mock_logger.warning.call_args
)
@pytest.mark.parametrize(
"scenario,expected_result,expected_exception",
[
("success_with_sync", "test-db-guardrail", None),
("success_sync_fails", "test-db-guardrail", None),
("database_failure", None, HTTPException),
("no_prisma_client", None, HTTPException),
],
ids=[
"success_with_immediate_sync",
"success_but_sync_fails",
"database_error",
"missing_prisma_client",
],
)
@pytest.mark.asyncio
async def test_update_guardrail_endpoint(
scenario,
expected_result,
expected_exception,
mocker,
mock_guardrail_registry,
mock_in_memory_handler,
):
"""Test update_guardrail endpoint with different scenarios"""
# Configure mocks based on scenario
mock_logger = None
if scenario == "success_with_sync":
mock_prisma_client = mocker.Mock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "success_sync_fails":
mock_prisma_client = mocker.Mock()
mock_in_memory_handler.update_in_memory_guardrail.side_effect = Exception(
"Sync failed"
)
mock_logger = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.verbose_proxy_logger"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "database_failure":
mock_prisma_client = mocker.Mock()
mock_guardrail_registry.update_guardrail_in_db.side_effect = Exception(
"Database error"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
elif scenario == "no_prisma_client":
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
# Run the test
if expected_exception:
with pytest.raises(expected_exception) as exc_info:
await update_guardrail(
"test-guardrail-id",
MOCK_UPDATE_REQUEST,
user_api_key_dict=MOCK_ADMIN_USER,
)
if scenario == "database_failure":
assert "Database error" in str(exc_info.value.detail)
elif scenario == "no_prisma_client":
assert "Prisma client not initialized" in str(exc_info.value.detail)
else:
result = await update_guardrail(
"test-guardrail-id", MOCK_UPDATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER
)
assert result["guardrail_id"] == expected_result
assert result["guardrail_name"] == "Test DB Guardrail"
mock_guardrail_registry.update_guardrail_in_db.assert_called_once_with(
guardrail_id="test-guardrail-id",
guardrail=MOCK_UPDATE_REQUEST.guardrail,
prisma_client=mocker.ANY,
)
mock_in_memory_handler.update_in_memory_guardrail.assert_called_once_with(
guardrail_id="test-guardrail-id", guardrail=mocker.ANY
)
if scenario == "success_sync_fails":
assert mock_logger is not None
mock_logger.warning.assert_called_once()
assert "Failed to update" in str(mock_logger.warning.call_args)
@pytest.mark.parametrize(
"scenario,expected_result,expected_exception",
[
("success_with_sync", "test-db-guardrail", None),
("success_sync_fails", "test-db-guardrail", None),
("database_failure", None, HTTPException),
("no_prisma_client", None, HTTPException),
],
ids=[
"success_with_immediate_sync",
"success_but_sync_fails",
"database_error",
"missing_prisma_client",
],
)
@pytest.mark.asyncio
async def test_patch_guardrail_endpoint(
scenario,
expected_result,
expected_exception,
mocker,
mock_guardrail_registry,
mock_in_memory_handler,
):
"""Test patch_guardrail endpoint with different scenarios"""
# Configure mocks based on scenario
mock_logger = None
if scenario == "success_with_sync":
mock_prisma_client = mocker.Mock()
mock_in_memory_handler.sync_guardrail_from_db = mocker.Mock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "success_sync_fails":
mock_prisma_client = mocker.Mock()
mock_in_memory_handler.sync_guardrail_from_db = mocker.Mock(
side_effect=Exception("Sync failed")
)
mock_logger = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.verbose_proxy_logger"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "database_failure":
mock_prisma_client = mocker.Mock()
mock_guardrail_registry.update_guardrail_in_db.side_effect = Exception(
"Database error"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
elif scenario == "no_prisma_client":
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
# Run the test
if expected_exception:
with pytest.raises(expected_exception) as exc_info:
await patch_guardrail(
"test-guardrail-id",
MOCK_PATCH_REQUEST,
user_api_key_dict=MOCK_ADMIN_USER,
)
if scenario == "database_failure":
assert "Database error" in str(exc_info.value.detail)
elif scenario == "no_prisma_client":
assert "Prisma client not initialized" in str(exc_info.value.detail)
else:
result = await patch_guardrail(
"test-guardrail-id", MOCK_PATCH_REQUEST, user_api_key_dict=MOCK_ADMIN_USER
)
assert result["guardrail_id"] == expected_result
assert result["guardrail_name"] == "Test DB Guardrail"
mock_guardrail_registry.update_guardrail_in_db.assert_called_once()
mock_in_memory_handler.sync_guardrail_from_db.assert_called_once_with(
guardrail=mocker.ANY
)
if scenario == "success_sync_fails":
assert mock_logger is not None
mock_logger.warning.assert_called_once()
assert "Failed to update" in str(mock_logger.warning.call_args)
@pytest.mark.parametrize(
"scenario,expected_result,expected_exception",
[
("success_with_sync", "test-db-guardrail", None),
("success_sync_fails", "test-db-guardrail", None),
],
ids=["success_with_immediate_sync", "success_but_sync_fails"],
)
@pytest.mark.asyncio
async def test_delete_guardrail_endpoint(
scenario,
expected_result,
expected_exception,
mocker,
mock_guardrail_registry,
mock_in_memory_handler,
):
"""Test delete_guardrail endpoint with different scenarios"""
# Configure mocks based on scenario
mock_prisma_client = mocker.Mock()
mock_logger = None
if scenario == "success_with_sync":
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
elif scenario == "success_sync_fails":
mock_in_memory_handler.delete_in_memory_guardrail.side_effect = Exception(
"Sync failed"
)
mock_logger = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.verbose_proxy_logger"
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY",
mock_guardrail_registry,
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
if expected_exception:
with pytest.raises(expected_exception):
await delete_guardrail(
guardrail_id=expected_result, user_api_key_dict=MOCK_ADMIN_USER
)
else:
result = await delete_guardrail(
guardrail_id=expected_result, user_api_key_dict=MOCK_ADMIN_USER
)
assert result == MOCK_DB_GUARDRAIL
mock_guardrail_registry.get_guardrail_by_id_from_db.assert_called_once_with(
guardrail_id=expected_result, prisma_client=mock_prisma_client
)
mock_guardrail_registry.delete_guardrail_from_db.assert_called_once_with(
guardrail_id=expected_result, prisma_client=mock_prisma_client
)
mock_in_memory_handler.delete_in_memory_guardrail.assert_called_once_with(
guardrail_id=expected_result
)
if scenario == "success_sync_fails":
assert mock_logger is not None
mock_logger.warning.assert_called_once()
assert "Failed to remove guardrail" in str(mock_logger.warning.call_args)
@pytest.mark.asyncio
async def test_apply_guardrail_not_found(mocker):
"""
Test apply_guardrail endpoint returns proper error when guardrail is not found.
"""
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
# Mock the GUARDRAIL_REGISTRY to return None (guardrail not found)
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = None
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
mock_proxy_logging = mocker.Mock()
mock_proxy_logging.post_call_failure_hook = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging)
mocker.patch("litellm.proxy.proxy_server.general_settings", {})
mocker.patch("litellm.proxy.proxy_server.proxy_config", mocker.Mock())
mocker.patch("litellm.proxy.proxy_server.version", "test")
# Create request
request = ApplyGuardrailRequest(
guardrail_name="non-existent-guardrail", text="Test input text"
)
# Mock user auth
mock_user_auth = UserAPIKeyAuth()
# Call endpoint and expect ProxyException
with pytest.raises(ProxyException) as exc_info:
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=mock_user_auth,
)
# Verify error details
assert str(exc_info.value.code) == "404"
assert "not found" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_apply_guardrail_execution_error(mocker):
"""
Test apply_guardrail endpoint handles exceptions from guardrail execution properly.
"""
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
# Mock guardrail that raises an exception
mock_guardrail = mocker.Mock()
mock_guardrail.apply_guardrail = AsyncMock(
side_effect=Exception("Bedrock guardrail failed: Violated guardrail policy")
)
# Mock the GUARDRAIL_REGISTRY
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
mock_logging_obj = mocker.Mock()
mock_logging_obj.async_failure_handler = AsyncMock()
mock_logging_obj.model_call_details = {}
mock_processor = mocker.Mock()
mock_processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj)
)
mocker.patch(
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
return_value=mock_processor,
)
mock_proxy_logging = mocker.Mock()
mock_proxy_logging.post_call_failure_hook = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging)
mocker.patch("litellm.proxy.proxy_server.general_settings", {})
mocker.patch("litellm.proxy.proxy_server.proxy_config", mocker.Mock())
mocker.patch("litellm.proxy.proxy_server.version", "test")
mocker.patch("litellm.litellm_core_utils.thread_pool_executor.executor")
# Create request
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail", text="Test input text with forbidden content"
)
# Mock user auth
mock_user_auth = UserAPIKeyAuth()
# Call endpoint and expect ProxyException
with pytest.raises(ProxyException) as exc_info:
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=mock_user_auth,
)
# Verify error is properly handled
assert "Bedrock guardrail failed" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_apply_guardrail_invokes_logging_pipeline(mocker):
mock_guardrail = mocker.Mock()
mock_guardrail.apply_guardrail = AsyncMock(return_value={"texts": ["masked"]})
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
mock_logging_obj = mocker.Mock()
mock_logging_obj.async_success_handler = AsyncMock()
mock_logging_obj.model_call_details = {}
mock_processor = mocker.Mock()
mock_processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj)
)
mocker.patch(
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
return_value=mock_processor,
)
mock_proxy_logging = mocker.Mock()
mock_proxy_logging.post_call_success_hook = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging)
mocker.patch("litellm.proxy.proxy_server.general_settings", {})
mocker.patch("litellm.proxy.proxy_server.proxy_config", mocker.Mock())
mocker.patch("litellm.proxy.proxy_server.version", "test")
mock_executor = mocker.Mock()
mocker.patch(
"litellm.litellm_core_utils.thread_pool_executor.executor", mock_executor
)
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail", text="hello@example.com"
)
response = await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
assert response.response_text == "masked"
mock_processor.common_processing_pre_call_logic.assert_awaited_once()
mock_proxy_logging.post_call_success_hook.assert_awaited_once()
mock_logging_obj.async_success_handler.assert_awaited_once()
assert mock_logging_obj.call_type == "pass_through_endpoint"
mock_executor.submit.assert_called_once()
assert mock_logging_obj.async_success_handler.await_args.kwargs["result"] == {
"response": {"response_text": "masked"}
}
def _patch_apply_guardrail_env(mocker, guardrail_result):
mock_guardrail = mocker.Mock()
mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result)
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
mock_logging_obj = mocker.Mock()
mock_logging_obj.async_success_handler = AsyncMock()
mock_logging_obj.model_call_details = {}
mock_processor = mocker.Mock()
mock_processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj)
)
mocker.patch(
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
return_value=mock_processor,
)
mock_proxy_logging = mocker.Mock()
mock_proxy_logging.post_call_success_hook = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging)
mocker.patch("litellm.proxy.proxy_server.general_settings", {})
mocker.patch("litellm.proxy.proxy_server.proxy_config", mocker.Mock())
mocker.patch("litellm.proxy.proxy_server.version", "test")
mocker.patch("litellm.litellm_core_utils.thread_pool_executor.executor")
return mock_guardrail
@pytest.mark.asyncio
async def test_apply_guardrail_forwards_metadata_to_guardrail(mocker):
"""Client-supplied metadata must reach apply_guardrail via request_data so
parameterized custom guardrails can read per-request configuration."""
mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]})
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="What are tax loopholes?",
metadata={"forbidden_topics": ["tax"]},
)
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
mock_guardrail.apply_guardrail.assert_awaited_once_with(
inputs={"texts": ["What are tax loopholes?"]},
request_data={"metadata": {"forbidden_topics": ["tax"]}},
input_type="request",
)
@pytest.mark.asyncio
async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker):
"""metadata and messages must coexist in request_data; the dict merge must
not clobber messages when both fields are sent."""
mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]})
messages = [{"role": "user", "content": "What are tax loopholes?"}]
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="What are tax loopholes?",
messages=messages,
metadata={"forbidden_topics": ["tax"]},
)
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
mock_guardrail.apply_guardrail.assert_awaited_once_with(
inputs={"texts": ["What are tax loopholes?"]},
request_data={
"messages": messages,
"metadata": {"forbidden_topics": ["tax"]},
},
input_type="request",
)
@pytest.mark.asyncio
async def test_apply_guardrail_omits_metadata_when_not_sent(mocker):
"""Without metadata, request_data stays empty (backward-compatible)."""
mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]})
request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello")
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
mock_guardrail.apply_guardrail.assert_awaited_once_with(
inputs={"texts": ["hello"]},
request_data={},
input_type="request",
)
@pytest.mark.asyncio
async def test_apply_guardrail_forwards_explicit_empty_messages_and_metadata(mocker):
"""Explicitly-sent empty messages/metadata must be forwarded, not dropped;
only omitted fields stay out of request_data."""
mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]})
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="hello",
messages=[],
metadata={},
)
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
mock_guardrail.apply_guardrail.assert_awaited_once_with(
inputs={"texts": ["hello"]},
request_data={"messages": [], "metadata": {}},
input_type="request",
)
@pytest.mark.asyncio
async def test_get_guardrail_info_endpoint_config_guardrail(mocker):
"""
Test get_guardrail_info endpoint returns proper response when guardrail is found in config.
"""
from litellm.proxy.guardrails.guardrail_endpoints import get_guardrail_info
# Mock prisma_client to not be None (patch at the source where it's imported from)
mock_prisma = mocker.Mock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# Mock the GUARDRAIL_REGISTRY to return None from DB (so it checks config)
mock_registry = mocker.Mock()
mock_registry.get_guardrail_by_id_from_db = AsyncMock(return_value=None)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
# Mock IN_MEMORY_GUARDRAIL_HANDLER at its source to return config guardrail
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.get_guardrail_by_id.return_value = MOCK_CONFIG_GUARDRAIL
mock_in_memory_handler.get_source.return_value = "config"
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
# Mock _get_masked_values to return values as-is
mocker.patch(
"litellm.litellm_core_utils.litellm_logging._get_masked_values",
side_effect=lambda x, **kwargs: x,
)
# Call endpoint and expect GuardrailInfoResponse
result = await get_guardrail_info(guardrail_id="test-config-guardrail")
# Verify the response is of the correct type
assert isinstance(result, GuardrailInfoResponse)
assert result.guardrail_id == "test-config-guardrail"
assert result.guardrail_name == "Test Config Guardrail"
assert result.guardrail_definition_location == "config"
@pytest.mark.asyncio
async def test_get_guardrail_info_endpoint_db_guardrail(mocker):
"""
Test get_guardrail_info endpoint returns proper response when guardrail is found in DB.
"""
from litellm.proxy.guardrails.guardrail_endpoints import get_guardrail_info
# Mock prisma_client to not be None (patch at the source where it's imported from)
mock_prisma = mocker.Mock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# Mock the GUARDRAIL_REGISTRY to return a guardrail from DB
mock_registry = mocker.Mock()
mock_registry.get_guardrail_by_id_from_db = AsyncMock(
return_value=MOCK_DB_GUARDRAIL
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry
)
# Mock IN_MEMORY_GUARDRAIL_HANDLER to return None
mock_in_memory_handler = mocker.Mock()
mock_in_memory_handler.get_guardrail_by_id.return_value = None
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_in_memory_handler,
)
# Call endpoint and expect GuardrailInfoResponse
result = await get_guardrail_info(guardrail_id="test-db-guardrail")
# Verify the response is of the correct type
assert isinstance(result, GuardrailInfoResponse)
assert result.guardrail_id == "test-db-guardrail"
assert result.guardrail_name == "Test DB Guardrail"
assert result.guardrail_definition_location == "db"
class TestBuildFieldDict:
"""Test _build_field_dict handles both enum and string ui_type values."""
def test_build_field_dict_with_string_ui_type(self):
"""Test that _build_field_dict works when ui_type is a plain string (e.g. BlockCodeExecutionGuardrailConfigModel)."""
from unittest.mock import MagicMock
from litellm.proxy.guardrails.guardrail_endpoints import _build_field_dict
field = MagicMock()
field.json_schema_extra = {
"ui_type": "multiselect",
"options": ["python", "javascript"],
}
result = _build_field_dict(
field=field,
field_annotation=str,
description="Test field",
required=False,
)
assert result["type"] == "multiselect"
assert result["description"] == "Test field"
def test_build_field_dict_with_enum_ui_type(self):
"""Test that _build_field_dict works when ui_type is a GuardrailParamUITypes enum."""
from unittest.mock import MagicMock
from litellm.proxy.guardrails.guardrail_endpoints import _build_field_dict
from litellm.types.guardrails import GuardrailParamUITypes
field = MagicMock()
field.json_schema_extra = {"ui_type": GuardrailParamUITypes.BOOL}
result = _build_field_dict(
field=field,
field_annotation=bool,
description="Test bool field",
required=True,
)
assert result["type"] == "bool"
assert result["required"] is True
# --- Team guardrail registration (register / submissions) ---
MOCK_REGISTER_REQUEST = RegisterGuardrailRequest(
guardrail_name="team-prompt-guard",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": "https://guardrails.example.com/validate",
},
guardrail_info={"description": "Team prompt injection detector"},
)
@pytest.mark.asyncio
async def test_register_guardrail_success(mocker):
"""Register creates a row with status pending_review and returns guardrail_id."""
mock_prisma = mocker.Mock()
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
created_row = mocker.Mock(
guardrail_id="reg-123",
guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name,
status="pending_review",
submitted_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.create = AsyncMock(return_value=created_row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_id="u1", user_email="alice@co.com", team_id="team-1")
result = await register_guardrail(MOCK_REGISTER_REQUEST, user)
assert result.guardrail_id == "reg-123"
assert result.guardrail_name == MOCK_REGISTER_REQUEST.guardrail_name
assert result.status == "pending_review"
mock_prisma.db.litellm_guardrailstable.create.assert_called_once()
call_data = mock_prisma.db.litellm_guardrailstable.create.call_args[1]["data"]
assert call_data["status"] == "pending_review"
assert call_data["guardrail_name"] == MOCK_REGISTER_REQUEST.guardrail_name
@pytest.mark.asyncio
async def test_register_guardrail_rejects_non_generic_api(mocker):
"""Register returns 400 when litellm_params.guardrail is not generic_guardrail_api."""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
req = RegisterGuardrailRequest(
guardrail_name="other-guard",
litellm_params={
"guardrail": "bedrock",
"mode": "pre_call",
"api_base": "https://x.com",
},
)
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1")
with pytest.raises(HTTPException) as exc_info:
await register_guardrail(req, user)
assert exc_info.value.status_code == 400
assert "generic_guardrail_api" in exc_info.value.detail
@pytest.mark.asyncio
async def test_register_guardrail_requires_team_id(mocker):
"""Register returns 400 when API key has no associated team_id."""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id=None)
with pytest.raises(HTTPException) as exc_info:
await register_guardrail(MOCK_REGISTER_REQUEST, user)
assert exc_info.value.status_code == 400
assert "team" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_register_guardrail_non_admin_cross_team_allowed(mocker):
"""Non-admin may register for a team in their user.teams list even if the key's team_id differs."""
mock_prisma = mocker.Mock()
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
created = mocker.Mock(
guardrail_id="g1",
guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name,
status="pending_review",
submitted_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.create = AsyncMock(return_value=created)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-alpha", "team-beta"]),
)
req = RegisterGuardrailRequest(
guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name,
team_id="team-beta",
litellm_params=MOCK_REGISTER_REQUEST.litellm_params,
)
user = UserAPIKeyAuth(
user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-alpha"
)
result = await register_guardrail(req, user)
assert result.guardrail_id == "g1"
@pytest.mark.asyncio
async def test_register_guardrail_non_admin_cross_team_forbidden(mocker):
"""Non-admin gets 403 when registering for a team they are not a member of."""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-alpha"]),
)
req = RegisterGuardrailRequest(
guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name,
team_id="team-other",
litellm_params=MOCK_REGISTER_REQUEST.litellm_params,
)
user = UserAPIKeyAuth(
user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-alpha"
)
with pytest.raises(HTTPException) as exc_info:
await register_guardrail(req, user)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_register_guardrail_duplicate_name(mocker):
"""Register returns 400 when guardrail_name already exists."""
mock_prisma = mocker.Mock()
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(
return_value={"guardrail_name": MOCK_REGISTER_REQUEST.guardrail_name}
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1")
with pytest.raises(HTTPException) as exc_info:
await register_guardrail(MOCK_REGISTER_REQUEST, user)
assert exc_info.value.status_code == 400
assert "already exists" in exc_info.value.detail
@pytest.mark.asyncio
async def test_list_guardrail_submissions_non_admin_scoped_to_own_teams(mocker):
"""Non-admin callers see only submissions for teams they belong to."""
mock_prisma = mocker.Mock()
own_team_row = mocker.Mock(
guardrail_id="mine",
guardrail_name="mine-guard",
status="pending_review",
team_id="team-mine",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
find_many = AsyncMock(return_value=[own_team_row])
mock_prisma.db.litellm_guardrailstable.find_many = find_many
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-mine"]),
)
user = UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER)
result = await list_guardrail_submissions(user_api_key_dict=user)
# DB query scoped to visible teams
where_clause = find_many.call_args.kwargs["where"]
assert where_clause["team_id"] == {"in": ["team-mine"]}
assert len(result.submissions) == 1
assert result.submissions[0].team_id == "team-mine"
# Summary counts reflect only visible teams
assert result.summary.total == 1
assert result.summary.pending_review == 1
@pytest.mark.asyncio
async def test_list_guardrail_submissions_non_admin_no_teams(mocker):
"""Non-admin caller with no team memberships gets an empty list (not 403)."""
mock_prisma = mocker.Mock()
find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_guardrailstable.find_many = find_many
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=[]),
)
user = UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER)
result = await list_guardrail_submissions(user_api_key_dict=user)
assert result.submissions == []
assert result.summary.total == 0
assert find_many.call_count == 0 # no DB query when user has no teams
@pytest.mark.asyncio
async def test_list_guardrail_submissions_non_admin_team_filter_forbidden(mocker):
"""Non-admin caller filtering by a team they're not in gets 403."""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-mine"]),
)
user = UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(HTTPException) as exc_info:
await list_guardrail_submissions(team_id="team-other", user_api_key_dict=user)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_list_guardrail_submissions_success(mocker):
"""List submissions returns list and summary for admin."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="sub-1",
guardrail_name="pending-guard",
status="pending_review",
team_id="t1",
litellm_params={
"guardrail": "generic_guardrail_api",
"api_base": "https://x.com",
},
guardrail_info={
"description": "A guard",
"submitted_by_user_id": "u1",
"submitted_by_email": "alice@co.com",
},
submitted_at=datetime.now(),
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[row])
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await list_guardrail_submissions(user_api_key_dict=user)
assert len(result.submissions) == 1
assert result.submissions[0].guardrail_id == "sub-1"
assert result.submissions[0].status == "pending_review"
assert result.submissions[0].team_guardrail is True # team_id is set
assert result.summary.total >= 1
assert result.summary.pending_review >= 1
@pytest.mark.asyncio
async def test_list_guardrail_submissions_returns_only_team_guardrails(mocker):
"""List submissions only returns team guardrails (team_id not null)."""
mock_prisma = mocker.Mock()
find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_guardrailstable.find_many = find_many
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
await list_guardrail_submissions(user_api_key_dict=user)
calls = find_many.call_args_list
assert len(calls) >= 1
first_where = calls[0].kwargs.get("where", {})
assert first_where.get("team_id") == {"not": None}
@pytest.mark.asyncio
async def test_list_guardrail_submissions_team_id_filter(mocker):
"""List submissions with team_id filter returns only that team's guardrails."""
mock_prisma = mocker.Mock()
row_abc = mocker.Mock(
guardrail_id="team-1",
guardrail_name="team-guard",
status="active",
team_id="team-abc",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
row_other = mocker.Mock(
guardrail_id="team-2",
guardrail_name="other-guard",
status="active",
team_id="team-xyz",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
find_many = AsyncMock(return_value=[row_abc, row_other])
mock_prisma.db.litellm_guardrailstable.find_many = find_many
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await list_guardrail_submissions(
user_api_key_dict=user, team_id="team-abc"
)
assert len(result.submissions) == 1
assert result.submissions[0].guardrail_id == "team-1"
assert result.submissions[0].team_guardrail is True
assert result.summary.total == 2 # summary counts all team guardrails
@pytest.mark.asyncio
async def test_get_guardrail_submission_not_found(mocker):
"""Get submission returns 404 when guardrail_id does not exist."""
mock_prisma = mocker.Mock()
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc_info:
await get_guardrail_submission("nonexistent-id", user)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_get_guardrail_submission_non_admin_own_team(mocker):
"""Non-admin caller can fetch a submission belonging to one of their teams."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="sub-1",
guardrail_name="team-guard",
status="pending_review",
team_id="team-mine",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-mine"]),
)
user = UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER)
result = await get_guardrail_submission("sub-1", user)
assert result.guardrail_id == "sub-1"
assert result.team_id == "team-mine"
@pytest.mark.asyncio
async def test_get_guardrail_submission_non_admin_other_team_forbidden(mocker):
"""Non-admin caller gets 403 when fetching a submission for a team they're not in."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="sub-1",
guardrail_name="team-guard",
status="pending_review",
team_id="team-other",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=["team-mine"]),
)
user = UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(HTTPException) as exc_info:
await get_guardrail_submission("sub-1", user)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_get_guardrail_submission_admin_viewer_other_team_allowed(mocker):
"""proxy_admin_viewer reads any team's submission without the membership check."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="sub-1",
guardrail_name="team-guard",
status="pending_review",
team_id="team-other",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mock_get_user_team_ids = mocker.patch(
"litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids",
AsyncMock(return_value=[]),
)
user = UserAPIKeyAuth(
user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
)
result = await get_guardrail_submission("sub-1", user)
assert result.guardrail_id == "sub-1"
assert result.team_id == "team-other"
mock_get_user_team_ids.assert_not_called()
@pytest.mark.asyncio
async def test_approve_guardrail_submission_success(mocker):
"""Approve sets status to active and initializes guardrail in memory."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="approve-me",
guardrail_name="my-guard",
status="pending_review",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": "https://g.com",
},
guardrail_info={},
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mock_handler = mocker.Mock()
mock_handler.initialize_guardrail = mocker.Mock()
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_handler,
)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await approve_guardrail_submission("approve-me", user)
assert result["status"] == "active"
assert result["guardrail_id"] == "approve-me"
mock_prisma.db.litellm_guardrailstable.update.assert_called_once()
call_data = mock_prisma.db.litellm_guardrailstable.update.call_args[1]["data"]
assert call_data["status"] == "active"
@pytest.mark.asyncio
async def test_approve_guardrail_submission_not_pending(mocker):
"""Approve returns 400 when status is not pending_review."""
mock_prisma = mocker.Mock()
row = mocker.Mock(guardrail_id="x", guardrail_name="y", status="active")
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc_info:
await approve_guardrail_submission("x", user)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_reject_guardrail_submission_success(mocker):
"""Reject sets status to rejected."""
mock_prisma = mocker.Mock()
row = mocker.Mock(guardrail_id="rej-1", guardrail_name="r", status="pending_review")
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await reject_guardrail_submission("rej-1", user)
assert result["status"] == "rejected"
mock_prisma.db.litellm_guardrailstable.update.assert_called_once()
call_data = mock_prisma.db.litellm_guardrailstable.update.call_args[1]["data"]
assert call_data["status"] == "rejected"
@pytest.mark.asyncio
async def test_reject_guardrail_submission_not_pending(mocker):
"""Reject returns 400 when status is not pending_review (e.g. already active)."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="already-active", guardrail_name="g", status="active"
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc_info:
await reject_guardrail_submission("already-active", user)
assert exc_info.value.status_code == 400
assert "not pending review" in exc_info.value.detail.lower()
# --- Tests for review fixes ---
@pytest.mark.asyncio
@pytest.mark.parametrize(
"api_base,expected_detail",
[
("file:///etc/passwd", "http or https scheme"),
("ftp://internal.host/data", "http or https scheme"),
("javascript:alert(1)", "http or https scheme"),
("://missing-scheme", "http or https scheme"),
("https://", "valid hostname"),
],
ids=[
"file_scheme",
"ftp_scheme",
"javascript_scheme",
"no_scheme",
"no_hostname",
],
)
async def test_register_guardrail_rejects_bad_api_base(
mocker, api_base, expected_detail
):
"""Register returns 400 when api_base has invalid scheme or missing hostname."""
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
req = RegisterGuardrailRequest(
guardrail_name="bad-url-guard",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": api_base,
},
)
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1")
with pytest.raises(HTTPException) as exc_info:
await register_guardrail(req, user)
assert exc_info.value.status_code == 400
assert expected_detail in exc_info.value.detail
@pytest.mark.asyncio
async def test_register_guardrail_accepts_valid_https_url(mocker):
"""Register accepts valid https api_base URLs."""
mock_prisma = mocker.Mock()
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
created_row = mocker.Mock(
guardrail_id="valid-url-123",
guardrail_name="valid-guard",
status="pending_review",
submitted_at=datetime.now(),
)
mock_prisma.db.litellm_guardrailstable.create = AsyncMock(return_value=created_row)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
req = RegisterGuardrailRequest(
guardrail_name="valid-guard",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": "https://guardrails.example.com/v1/check",
},
)
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1")
result = await register_guardrail(req, user)
assert result.guardrail_id == "valid-url-123"
assert result.status == "pending_review"
@pytest.mark.asyncio
async def test_approve_guardrail_init_failure_returns_warning(mocker):
"""Approve returns a warning field when in-memory initialization fails."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="warn-me",
guardrail_name="fragile-guard",
status="pending_review",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": "https://g.com",
},
guardrail_info={},
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mock_handler = mocker.Mock()
mock_handler.initialize_guardrail = mocker.Mock(
side_effect=Exception("missing dependency")
)
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_handler,
)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await approve_guardrail_submission("warn-me", user)
assert result["status"] == "active"
assert "warning" in result
assert "failed to initialize" in result["warning"].lower()
assert "missing dependency" in result["warning"]
@pytest.mark.asyncio
async def test_approve_guardrail_no_warning_on_success(mocker):
"""Approve does NOT include a warning field when init succeeds."""
mock_prisma = mocker.Mock()
row = mocker.Mock(
guardrail_id="ok-guard",
guardrail_name="good-guard",
status="pending_review",
litellm_params={
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"api_base": "https://g.com",
},
guardrail_info={},
)
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
mock_handler = mocker.Mock()
mock_handler.initialize_guardrail = mocker.Mock() # no exception
mocker.patch(
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
mock_handler,
)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
result = await approve_guardrail_submission("ok-guard", user)
assert result["status"] == "active"
assert "warning" not in result
@pytest.mark.asyncio
async def test_list_submissions_single_db_query(mocker):
"""List submissions makes exactly one find_many call (no redundant query)."""
mock_prisma = mocker.Mock()
find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_guardrailstable.find_many = find_many
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
await list_guardrail_submissions(user_api_key_dict=user)
assert find_many.call_count == 1
@pytest.mark.asyncio
async def test_list_submissions_summary_counts_unaffected_by_filters(mocker):
"""Summary counts reflect all team guardrails regardless of status filter."""
mock_prisma = mocker.Mock()
pending_row = mocker.Mock(
guardrail_id="p1",
guardrail_name="p",
status="pending_review",
team_id="t1",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
active_row = mocker.Mock(
guardrail_id="a1",
guardrail_name="a",
status="active",
team_id="t1",
litellm_params={},
guardrail_info={},
submitted_at=None,
reviewed_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
all_rows = [pending_row, active_row]
mock_prisma.db.litellm_guardrailstable.find_many = AsyncMock(return_value=all_rows)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
# Filter to only pending, but summary should still show both
result = await list_guardrail_submissions(
status="pending_review", user_api_key_dict=user
)
assert len(result.submissions) == 1 # filtered
assert result.summary.total == 2 # unfiltered
assert result.summary.pending_review == 1
assert result.summary.active == 1
@pytest.mark.asyncio
async def test_get_guardrail_ui_settings_returns_per_provider_supported_modes():
"""
Regression test for LIT-4226. The Admin UI used to render `pre_mcp_call` as a
selectable mode for every guardrail because the settings endpoint returned a
single global `supported_modes` list. The proxy then rejected the save because
Content Filter and Tool Permission do not accept `pre_mcp_call`. The endpoint
must now return per-provider modes so the UI can filter its dropdown.
"""
result = await get_guardrail_ui_settings()
modes_by_provider = result.supported_modes_by_provider
# Content Filter now supports pre_mcp_call (LIT-4226 feature half) but not
# during_mcp_call; Tool Permission still supports neither, and the settings
# endpoint must reflect both so the UI shows exactly the savable modes.
assert "pre_mcp_call" in modes_by_provider["litellm_content_filter"]
assert "during_mcp_call" not in modes_by_provider["litellm_content_filter"]
assert modes_by_provider["tool_permission"] == ["pre_call", "post_call"]
# MCP-capable guardrails must still advertise the MCP hooks so users who
# picked one of them can actually configure pre_mcp_call / during_mcp_call.
for provider in ("bedrock", "panw_prisma_airs", "cisco_ai_defense", "custom_code", "pillar"):
assert "pre_mcp_call" in modes_by_provider[provider], provider
assert "during_mcp_call" in modes_by_provider[provider], provider
# The union list stays exhaustive for legacy clients that ignore the
# per-provider map; it must cover every declared GuardrailEventHooks value.
from litellm.types.guardrails import GuardrailEventHooks
assert set(result.supported_modes) == {m.value for m in GuardrailEventHooks}
@pytest.mark.asyncio
async def test_ui_settings_map_matches_runtime_supported_event_hooks():
"""
Regression guard against the two-copy-of-the-list drift risk. The map the
UI reads must agree with what CustomGuardrail._validate_event_hook accepts
at save time, otherwise the bug in LIT-4226 comes back one classname at a
time as future guardrails drift.
"""
from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry
result = await get_guardrail_ui_settings()
for provider, guardrail_class in guardrail_class_registry.items():
declared = guardrail_class.get_supported_event_hooks()
if declared is None:
assert (
provider not in result.supported_modes_by_provider
), f"{provider} returned None from classmethod but appears in map"
continue
assert provider in result.supported_modes_by_provider, provider
assert result.supported_modes_by_provider[provider] == [
hook.value for hook in declared
], provider
def test_content_filter_runtime_rejects_unsupported_mcp_hook():
"""
Locks the runtime side of the LIT-4226 contract: the ContentFilterGuardrail
validator must reject a hook missing from its supported list at
construction. pre_mcp_call is supported since the LIT-4226 feature half, so
during_mcp_call is the unsupported example now. If someone widens the
UI classmethod but forgets to widen the runtime supported_event_hooks (or
vice versa), the two-lists-must-agree test above catches the drift and this
test catches the specific bug the ticket reported.
"""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.guardrails import GuardrailEventHooks
with pytest.raises(ValueError, match="not in the supported event hooks"):
ContentFilterGuardrail(
guardrail_name="lit4226-runtime-check",
event_hook=GuardrailEventHooks.during_mcp_call,
)
def test_model_armor_runtime_supported_event_hooks_match_classmethod():
"""
Regression for the drift Round 2 caught: the ModelArmorGuardrail classmethod
declared its supported hooks for the UI, but __init__ did not seed the
runtime instance's `supported_event_hooks` from that classmethod, so the
runtime validator accepted any hook (including nonsense like logging_only)
while the UI hid them. Ensures the two sides agree at instantiation time.
"""
from litellm.proxy.guardrails.guardrail_hooks.model_armor.model_armor import (
ModelArmorGuardrail,
)
instance = ModelArmorGuardrail(
guardrail_name="lit4226-model-armor-drift",
template_id="t",
project_id="p",
)
assert instance.supported_event_hooks == ModelArmorGuardrail.get_supported_event_hooks()
def test_strict_guardrail_modes_flag_controls_raise_vs_warn(monkeypatch, caplog):
"""
Escape hatch for the boot-time behavior change. Deployments upgrading from
a build where a guardrail previously silently no-op'd on an unsupported
mode should be able to set LITELLM_STRICT_GUARDRAIL_MODES=false and boot
with a warning instead of a hard failure while they fix their config.
"""
import logging
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.guardrails import GuardrailEventHooks
monkeypatch.delenv("LITELLM_STRICT_GUARDRAIL_MODES", raising=False)
with pytest.raises(ValueError, match="not in the supported event hooks"):
ContentFilterGuardrail(
guardrail_name="lit4226-strict-default",
event_hook=GuardrailEventHooks.during_mcp_call,
)
monkeypatch.setenv("LITELLM_STRICT_GUARDRAIL_MODES", "false")
with caplog.at_level(logging.WARNING):
instance = ContentFilterGuardrail(
guardrail_name="lit4226-strict-off",
event_hook=GuardrailEventHooks.during_mcp_call,
)
assert instance is not None
assert any("not in the supported event hooks" in rec.message for rec in caplog.records)
def test_field_type_inference_handles_pep604_unions():
from litellm.proxy.guardrails.guardrail_endpoints import (
_get_field_type_from_annotation,
_unwrap_optional_type,
)
assert _get_field_type_from_annotation(Optional[int]) == "number"
assert _get_field_type_from_annotation(int | None) == "number"
assert _get_field_type_from_annotation(list[str] | None) == "array"
assert _get_field_type_from_annotation(bool | None) == "boolean"
assert _unwrap_optional_type(str | None) is str