mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge 0bf552792c into c1aaaae4e3
This commit is contained in:
commit
3b18309cf1
5 changed files with 642 additions and 0 deletions
|
|
@ -0,0 +1,87 @@
|
|||
# Llama Guard Guardrail
|
||||
|
||||
Content-safety guardrail backed by a [Llama Guard](https://github.com/meta-llama/PurpleLlama/tree/main/Llama-Guard3) classifier model. It screens request and/or response text against Meta's MLCommons hazard taxonomy (S1–S14) and blocks content the model flags as `unsafe`.
|
||||
|
||||
The Llama Guard model is called through LiteLLM itself, so any provider that serves a Llama Guard model works: `together_ai`, `groq`, `fireworks_ai`, `ollama`, `huggingface`, self-hosted `hosted_vllm`, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-call (screen the user prompt), during-call (parallel moderation), and post-call (screen the model's response) modes
|
||||
- Enforce the full MLCommons taxonomy or a subset of hazard categories
|
||||
- Fully custom category block via `unsafe_content_categories`
|
||||
- Provider-agnostic: the classification prompt is self-contained, so it does not depend on the serving provider applying a Llama Guard chat template
|
||||
- Fails open (logs and allows the request) if the classifier is unreachable, so an outage of the safety model does not take down traffic
|
||||
- Violation errors name the exact hazard categories that were triggered
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required parameters
|
||||
|
||||
- `model`: the Llama Guard model to call, e.g. `together_ai/meta-llama/Llama-Guard-4-12B`, `groq/llama-guard-3-8b`, `ollama/llama-guard3`.
|
||||
|
||||
### Optional parameters
|
||||
|
||||
- `api_base`: base URL for the Llama Guard model endpoint.
|
||||
- `api_key`: API key for the Llama Guard model endpoint (`os.environ/...` is supported).
|
||||
- `categories`: list of hazard codes to enforce (e.g. `["S1", "S10", "S11"]`). Defaults to the full `S1`–`S14` taxonomy.
|
||||
- `unsafe_content_categories`: a fully custom category block that overrides the built-in taxonomy text.
|
||||
- `default_on` (default `false`): apply this guardrail to every request without opting in per request.
|
||||
|
||||
### Hazard taxonomy (default)
|
||||
|
||||
| Code | Category | Code | Category |
|
||||
|------|----------|------|----------|
|
||||
| S1 | Violent Crimes | S8 | Intellectual Property |
|
||||
| S2 | Non-Violent Crimes | S9 | Indiscriminate Weapons |
|
||||
| S3 | Sex-Related Crimes | S10 | Hate |
|
||||
| S4 | Child Sexual Exploitation | S11 | Suicide & Self-Harm |
|
||||
| S5 | Defamation | S12 | Sexual Content |
|
||||
| S6 | Specialized Advice | S13 | Elections |
|
||||
| S7 | Privacy | S14 | Code Interpreter Abuse |
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Screen the user prompt (pre-call)
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "llama-guard-input"
|
||||
litellm_params:
|
||||
guardrail: llama_guard
|
||||
mode: pre_call
|
||||
default_on: true
|
||||
model: together_ai/meta-llama/Llama-Guard-4-12B
|
||||
api_key: os.environ/TOGETHERAI_API_KEY
|
||||
```
|
||||
|
||||
### Screen the model's response (post-call)
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "llama-guard-output"
|
||||
litellm_params:
|
||||
guardrail: llama_guard
|
||||
mode: post_call
|
||||
default_on: true
|
||||
model: groq/llama-guard-3-8b
|
||||
api_key: os.environ/GROQ_API_KEY
|
||||
```
|
||||
|
||||
### Enforce only a subset of categories
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "llama-guard-strict"
|
||||
litellm_params:
|
||||
guardrail: llama_guard
|
||||
mode: pre_call
|
||||
model: ollama/llama-guard3
|
||||
api_base: http://localhost:11434
|
||||
categories: ["S1", "S9", "S11"] # violent crimes, weapons, self-harm
|
||||
```
|
||||
|
||||
When Llama Guard flags a request, LiteLLM raises a `content_policy_violation` error that lists the triggered categories, for example:
|
||||
|
||||
```
|
||||
Violated Llama Guard content policy. Categories: S9 (Indiscriminate Weapons)
|
||||
```
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
|
||||
from .llama_guard import LlamaGuardGuardrail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
|
||||
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"):
|
||||
import litellm
|
||||
|
||||
model: Final = litellm_params.get("model")
|
||||
if not model:
|
||||
raise ValueError("llama_guard guardrail requires `model` in litellm_params")
|
||||
|
||||
_guardrail: Final = LlamaGuardGuardrail(
|
||||
model=model,
|
||||
guardrail_name=guardrail.get("guardrail_name", ""),
|
||||
api_base=litellm_params.get("api_base"),
|
||||
api_key=litellm_params.get("api_key"),
|
||||
categories=litellm_params.get("categories"),
|
||||
unsafe_content_categories=litellm_params.get("unsafe_content_categories"),
|
||||
event_hook=litellm_params.get("mode"),
|
||||
default_on=litellm_params.get("default_on", False),
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(_guardrail)
|
||||
|
||||
return _guardrail
|
||||
|
||||
|
||||
guardrail_initializer_registry: Final = {
|
||||
SupportedGuardrailIntegrations.LLAMA_GUARD.value: initialize_guardrail,
|
||||
}
|
||||
|
||||
|
||||
guardrail_class_registry: Final = {
|
||||
SupportedGuardrailIntegrations.LLAMA_GUARD.value: LlamaGuardGuardrail,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LlamaGuardGuardrail",
|
||||
"guardrail_class_registry",
|
||||
"guardrail_initializer_registry",
|
||||
"initialize_guardrail",
|
||||
]
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
# +-------------------------------------------------------------+
|
||||
#
|
||||
# Llama Guard content-safety guardrail
|
||||
# Classifies request/response text with a Llama Guard model
|
||||
# (Meta's LLM-based content-safety classifier) and blocks
|
||||
# content that violates the configured MLCommons hazard
|
||||
# categories.
|
||||
#
|
||||
# https://github.com/meta-llama/PurpleLlama/tree/main/Llama-Guard3
|
||||
#
|
||||
# +-------------------------------------------------------------+
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import CallTypesLiteral, Choices, LLMResponseTypes, ModelResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm import DualCache
|
||||
|
||||
# The MLCommons hazard taxonomy Llama Guard 3 / 4 is trained on (S1-S14).
|
||||
# https://github.com/meta-llama/PurpleLlama/blob/main/Llama-Guard3/8B/MODEL_CARD.md
|
||||
DEFAULT_UNSAFE_CATEGORIES: Final[dict[str, str]] = {
|
||||
"S1": "Violent Crimes",
|
||||
"S2": "Non-Violent Crimes",
|
||||
"S3": "Sex-Related Crimes",
|
||||
"S4": "Child Sexual Exploitation",
|
||||
"S5": "Defamation",
|
||||
"S6": "Specialized Advice",
|
||||
"S7": "Privacy",
|
||||
"S8": "Intellectual Property",
|
||||
"S9": "Indiscriminate Weapons",
|
||||
"S10": "Hate",
|
||||
"S11": "Suicide & Self-Harm",
|
||||
"S12": "Sexual Content",
|
||||
"S13": "Elections",
|
||||
"S14": "Code Interpreter Abuse",
|
||||
}
|
||||
|
||||
# Roles as Llama Guard refers to them in its prompt / assessment target.
|
||||
_USER_ROLE: Final = "User"
|
||||
_AGENT_ROLE: Final = "Agent"
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""Flatten a chat message ``content`` (str or multimodal parts) to text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
class LlamaGuardGuardrail(CustomGuardrail):
|
||||
"""Content-safety guardrail backed by a Llama Guard classifier model.
|
||||
|
||||
The configured ``model`` can be any Llama Guard model reachable through
|
||||
LiteLLM (e.g. ``together_ai/meta-llama/Llama-Guard-4-12B``,
|
||||
``groq/llama-guard-3-8b``, ``ollama/llama-guard3``). On each event the
|
||||
guardrail renders the conversation into Llama Guard's classification
|
||||
prompt, asks the model whether the last turn is safe, and raises a
|
||||
content-policy error when it is flagged ``unsafe``.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
|
||||
return [
|
||||
GuardrailEventHooks.pre_call,
|
||||
GuardrailEventHooks.during_call,
|
||||
GuardrailEventHooks.post_call,
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
guardrail_name: str | None = None,
|
||||
api_base: str | None = None,
|
||||
api_key: str | None = None,
|
||||
categories: Sequence[str] | None = None,
|
||||
unsafe_content_categories: str | None = None,
|
||||
event_hook: GuardrailEventHooks | list[GuardrailEventHooks] | str | None = None,
|
||||
default_on: bool = False,
|
||||
**kwargs: Any, # kwargs-ok: forwarded verbatim to CustomGuardrail.__init__
|
||||
) -> None:
|
||||
if not model:
|
||||
raise ValueError("llama_guard guardrail requires a `model`")
|
||||
self.model: Final = model
|
||||
self.api_base: Final = api_base
|
||||
self.api_key: Final = api_key
|
||||
|
||||
# Restrict enforcement to a subset of hazard codes when configured,
|
||||
# otherwise enforce the full MLCommons taxonomy.
|
||||
if categories:
|
||||
selected = {
|
||||
code.upper(): DEFAULT_UNSAFE_CATEGORIES[code.upper()]
|
||||
for code in categories
|
||||
if code.upper() in DEFAULT_UNSAFE_CATEGORIES
|
||||
}
|
||||
if not selected:
|
||||
raise ValueError(f"llama_guard `categories` must be a subset of {sorted(DEFAULT_UNSAFE_CATEGORIES)}")
|
||||
self.categories = selected
|
||||
else:
|
||||
self.categories = dict(DEFAULT_UNSAFE_CATEGORIES)
|
||||
|
||||
# Allow a fully custom category block to override the taxonomy text.
|
||||
self.unsafe_content_categories: Final = unsafe_content_categories
|
||||
|
||||
kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks()))
|
||||
super().__init__(
|
||||
guardrail_name=guardrail_name,
|
||||
event_hook=event_hook,
|
||||
default_on=default_on,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Prompt construction + response parsing
|
||||
# ------------------------------------------------------------------ #
|
||||
def _category_block(self) -> str:
|
||||
if self.unsafe_content_categories:
|
||||
return self.unsafe_content_categories
|
||||
return "\n".join(f"{code}: {name}." for code, name in self.categories.items())
|
||||
|
||||
def _render_conversation(self, messages: Sequence[dict]) -> str:
|
||||
lines: list[str] = []
|
||||
for message in messages:
|
||||
role = _AGENT_ROLE if message.get("role") == "assistant" else _USER_ROLE
|
||||
text = _extract_text(message.get("content"))
|
||||
if text:
|
||||
lines.append(f"{role}: {text}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
def _build_prompt(self, messages: Sequence[dict], role_to_check: str) -> str:
|
||||
return (
|
||||
f"Task: Check if there is unsafe content in '{role_to_check}' messages in "
|
||||
"conversations according to our safety policy with the below categories.\n\n"
|
||||
"<BEGIN UNSAFE CONTENT CATEGORIES>\n"
|
||||
f"{self._category_block()}\n"
|
||||
"<END UNSAFE CONTENT CATEGORIES>\n\n"
|
||||
"<BEGIN CONVERSATION>\n\n"
|
||||
f"{self._render_conversation(messages)}\n\n"
|
||||
"<END CONVERSATION>\n\n"
|
||||
f"Provide your safety assessment for ONLY THE LAST {role_to_check} message in the "
|
||||
"above conversation:\n"
|
||||
" - First line must read 'safe' or 'unsafe'.\n"
|
||||
" - If unsafe, a second line must include a comma-separated list of violated categories."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_response(content: str | None) -> tuple[bool, list[str]]:
|
||||
"""Return ``(is_unsafe, [category_codes])`` from a Llama Guard completion."""
|
||||
if not content:
|
||||
return False, []
|
||||
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
|
||||
if not lines or not lines[0].lower().startswith("unsafe"):
|
||||
return False, []
|
||||
codes: list[str] = []
|
||||
if len(lines) > 1:
|
||||
for token in lines[1].replace(",", " ").split():
|
||||
code = token.strip().upper()
|
||||
if code:
|
||||
codes.append(code)
|
||||
return True, codes
|
||||
|
||||
async def _classify(self, messages: Sequence[dict], role_to_check: str) -> tuple[bool, list[str]]:
|
||||
prompt: Final = self._build_prompt(messages, role_to_check)
|
||||
response = await litellm.acompletion(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
api_base=self.api_base,
|
||||
api_key=self.api_key,
|
||||
temperature=0.0,
|
||||
max_tokens=20,
|
||||
)
|
||||
content: str | None = None
|
||||
if isinstance(response, ModelResponse) and response.choices:
|
||||
choice = response.choices[0]
|
||||
if isinstance(choice, Choices):
|
||||
content = choice.message.content
|
||||
return self._parse_response(content)
|
||||
|
||||
def _raise_violation(self, codes: Sequence[str]) -> None:
|
||||
named = [f"{code} ({self.categories[code]})" if code in self.categories else code for code in codes]
|
||||
detail = ", ".join(named) if named else "unspecified category"
|
||||
verbose_proxy_logger.info("Llama Guard: unsafe content detected, categories=%s", named)
|
||||
raise ProxyException(
|
||||
message=f"Violated Llama Guard content policy. Categories: {detail}",
|
||||
type="invalid_request_error",
|
||||
param=None,
|
||||
code=400,
|
||||
openai_code="content_policy_violation",
|
||||
)
|
||||
|
||||
async def _guard_input(self, data: dict, event_type: GuardrailEventHooks) -> dict:
|
||||
if not self.should_run_guardrail(data, event_type):
|
||||
return data
|
||||
messages = data.get("messages") or []
|
||||
if not messages:
|
||||
return data
|
||||
try:
|
||||
is_unsafe, codes = await self._classify(messages, _USER_ROLE)
|
||||
except ProxyException:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - fail open so a classifier outage does not drop traffic
|
||||
verbose_proxy_logger.warning("Llama Guard input classification failed, failing open: %s", e)
|
||||
return data
|
||||
if is_unsafe:
|
||||
self._raise_violation(codes)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Guardrail hooks
|
||||
# ------------------------------------------------------------------ #
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: DualCache,
|
||||
data: dict,
|
||||
call_type: CallTypesLiteral,
|
||||
) -> dict | None:
|
||||
return await self._guard_input(data, GuardrailEventHooks.pre_call)
|
||||
|
||||
async def async_moderation_hook(
|
||||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
call_type: CallTypesLiteral,
|
||||
) -> dict:
|
||||
return await self._guard_input(data, GuardrailEventHooks.during_call)
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: LLMResponseTypes,
|
||||
) -> LLMResponseTypes:
|
||||
if not self.should_run_guardrail(data, GuardrailEventHooks.post_call):
|
||||
return response
|
||||
if not (isinstance(response, ModelResponse) and response.choices):
|
||||
return response
|
||||
base_messages = list(data.get("messages") or [])
|
||||
for choice in response.choices:
|
||||
if not isinstance(choice, Choices):
|
||||
continue
|
||||
output_text = choice.message.content or ""
|
||||
if not output_text:
|
||||
continue
|
||||
conversation = base_messages + [{"role": "assistant", "content": output_text}]
|
||||
try:
|
||||
is_unsafe, codes = await self._classify(conversation, _AGENT_ROLE)
|
||||
except ProxyException:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - fail open so a classifier outage does not drop traffic
|
||||
verbose_proxy_logger.warning("Llama Guard output classification failed, failing open: %s", e)
|
||||
continue
|
||||
if is_unsafe:
|
||||
self._raise_violation(codes)
|
||||
return response
|
||||
|
|
@ -127,6 +127,7 @@ class SupportedGuardrailIntegrations(Enum):
|
|||
AKTO = "akto"
|
||||
MCP_JWT_SIGNER = "mcp_jwt_signer"
|
||||
LLM_AS_A_JUDGE = "llm_as_a_judge"
|
||||
LLAMA_GUARD = "llama_guard"
|
||||
DEEPKEEP = "deepkeep"
|
||||
QOSTODIAN_NEXUS = "qostodian_nexus"
|
||||
RUBRIK = "rubrik"
|
||||
|
|
|
|||
236
tests/test_litellm/proxy/guardrails/test_llama_guard.py
Normal file
236
tests/test_litellm/proxy/guardrails/test_llama_guard.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Unit tests for the Llama Guard content-safety guardrail hook."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_hooks.llama_guard import initialize_guardrail
|
||||
from litellm.proxy.guardrails.guardrail_hooks.llama_guard.llama_guard import (
|
||||
DEFAULT_UNSAFE_CATEGORIES,
|
||||
LlamaGuardGuardrail,
|
||||
_extract_text,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _make_guardrail(**overrides) -> LlamaGuardGuardrail:
|
||||
kwargs = dict(
|
||||
model="together_ai/meta-llama/Llama-Guard-4-12B",
|
||||
guardrail_name="test_llama_guard",
|
||||
default_on=True,
|
||||
event_hook=[
|
||||
GuardrailEventHooks.pre_call,
|
||||
GuardrailEventHooks.during_call,
|
||||
GuardrailEventHooks.post_call,
|
||||
],
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
guardrail = LlamaGuardGuardrail(**kwargs)
|
||||
# Isolate the classify/block logic from the run-gating logic.
|
||||
guardrail.should_run_guardrail = lambda *args, **kwargs: True # type: ignore[method-assign]
|
||||
return guardrail
|
||||
|
||||
|
||||
def _guard_response(text: str) -> ModelResponse:
|
||||
"""A ModelResponse standing in for the Llama Guard model's verdict."""
|
||||
return ModelResponse(choices=[Choices(index=0, message=Message(role="assistant", content=text))])
|
||||
|
||||
|
||||
def _completion_response(text: str) -> ModelResponse:
|
||||
"""A ModelResponse standing in for the real completion being screened."""
|
||||
return ModelResponse(choices=[Choices(index=0, message=Message(role="assistant", content=text))])
|
||||
|
||||
|
||||
_KEY = UserAPIKeyAuth()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _extract_text
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_extract_text_str():
|
||||
assert _extract_text("hello") == "hello"
|
||||
|
||||
|
||||
def test_extract_text_multimodal_list_keeps_only_text_parts():
|
||||
content = [
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
{"type": "text", "text": "world"},
|
||||
]
|
||||
assert _extract_text(content) == "hello\nworld"
|
||||
|
||||
|
||||
def test_extract_text_non_string_returns_empty():
|
||||
assert _extract_text(None) == ""
|
||||
assert _extract_text(123) == ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _parse_response
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("safe", (False, [])),
|
||||
(" safe ", (False, [])),
|
||||
("unsafe\nS1,S10", (True, ["S1", "S10"])),
|
||||
("unsafe\nS1, S5, S14", (True, ["S1", "S5", "S14"])),
|
||||
("Unsafe\ns9", (True, ["S9"])),
|
||||
("unsafe", (True, [])),
|
||||
("", (False, [])),
|
||||
(None, (False, [])),
|
||||
],
|
||||
)
|
||||
def test_parse_response(raw, expected):
|
||||
assert LlamaGuardGuardrail._parse_response(raw) == expected
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# category configuration
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_default_categories_cover_full_taxonomy():
|
||||
guardrail = _make_guardrail()
|
||||
assert guardrail.categories == DEFAULT_UNSAFE_CATEGORIES
|
||||
block = guardrail._category_block()
|
||||
assert "S1: Violent Crimes." in block
|
||||
assert "S14: Code Interpreter Abuse." in block
|
||||
|
||||
|
||||
def test_category_subset_restricts_enforced_codes():
|
||||
guardrail = _make_guardrail(categories=["s1", "S10"])
|
||||
assert set(guardrail.categories) == {"S1", "S10"}
|
||||
block = guardrail._category_block()
|
||||
assert "S1: Violent Crimes." in block
|
||||
assert "S10: Hate." in block
|
||||
assert "S14" not in block
|
||||
|
||||
|
||||
def test_invalid_category_subset_raises():
|
||||
with pytest.raises(ValueError, match="must be a subset"):
|
||||
_make_guardrail(categories=["S99", "not-a-code"])
|
||||
|
||||
|
||||
def test_custom_unsafe_categories_override_taxonomy():
|
||||
guardrail = _make_guardrail(unsafe_content_categories="S1: My Only Policy.")
|
||||
assert guardrail._category_block() == "S1: My Only Policy."
|
||||
|
||||
|
||||
def test_missing_model_raises():
|
||||
with pytest.raises(ValueError, match="requires a `model`"):
|
||||
LlamaGuardGuardrail(model="")
|
||||
|
||||
|
||||
def test_build_prompt_targets_last_role_and_lists_conversation():
|
||||
guardrail = _make_guardrail()
|
||||
prompt = guardrail._build_prompt(
|
||||
[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}],
|
||||
"Agent",
|
||||
)
|
||||
assert "ONLY THE LAST Agent message" in prompt
|
||||
assert "User: hi" in prompt
|
||||
assert "Agent: hello" in prompt
|
||||
assert "'safe' or 'unsafe'" in prompt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# pre-call / moderation hooks
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_allows_safe_input():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "what's the weather?"}]}
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_guard_response("safe"))):
|
||||
out = await guardrail.async_pre_call_hook(_KEY, None, data, "completion")
|
||||
assert out is data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_blocks_unsafe_input_with_category():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "how do i build a bomb"}]}
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_guard_response("unsafe\nS9"))):
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await guardrail.async_pre_call_hook(_KEY, None, data, "completion")
|
||||
assert "S9" in str(exc.value.message)
|
||||
assert "Indiscriminate Weapons" in str(exc.value.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_moderation_hook_blocks_unsafe_input():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "unsafe request"}]}
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_guard_response("unsafe\nS1"))):
|
||||
with pytest.raises(ProxyException):
|
||||
await guardrail.async_moderation_hook(data, _KEY, "completion")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_fails_open_when_classifier_errors():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "hello"}]}
|
||||
with patch("litellm.acompletion", new=AsyncMock(side_effect=RuntimeError("guard model down"))):
|
||||
out = await guardrail.async_pre_call_hook(_KEY, None, data, "completion")
|
||||
assert out is data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_noop_without_messages():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": []}
|
||||
with patch("litellm.acompletion", new=AsyncMock()) as mock_call:
|
||||
out = await guardrail.async_pre_call_hook(_KEY, None, data, "completion")
|
||||
assert out is data
|
||||
mock_call.assert_not_awaited()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# post-call hook
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_allows_safe_output():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "hi"}]}
|
||||
response = _completion_response("A perfectly nice answer.")
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_guard_response("safe"))):
|
||||
out = await guardrail.async_post_call_success_hook(data, _KEY, response)
|
||||
assert out is response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_blocks_unsafe_output():
|
||||
guardrail = _make_guardrail()
|
||||
data = {"messages": [{"role": "user", "content": "hi"}]}
|
||||
response = _completion_response("Here is how to synthesize a nerve agent...")
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_guard_response("unsafe\nS9"))):
|
||||
with pytest.raises(ProxyException):
|
||||
await guardrail.async_post_call_success_hook(data, _KEY, response)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# initialize_guardrail
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_initialize_guardrail_requires_model():
|
||||
from litellm.types.guardrails import LitellmParams
|
||||
|
||||
params = LitellmParams(guardrail="llama_guard", mode="pre_call")
|
||||
with pytest.raises(ValueError, match="requires `model`"):
|
||||
initialize_guardrail(params, {"guardrail_name": "g"})
|
||||
|
||||
|
||||
def test_initialize_guardrail_builds_instance():
|
||||
from litellm.types.guardrails import LitellmParams
|
||||
|
||||
params = LitellmParams(
|
||||
guardrail="llama_guard",
|
||||
mode="pre_call",
|
||||
model="groq/llama-guard-3-8b",
|
||||
)
|
||||
instance = initialize_guardrail(params, {"guardrail_name": "prod_guard"})
|
||||
assert isinstance(instance, LlamaGuardGuardrail)
|
||||
assert instance.model == "groq/llama-guard-3-8b"
|
||||
assert instance.guardrail_name == "prod_guard"
|
||||
Loading…
Add table
Reference in a new issue