This commit is contained in:
eeshsaxena 2026-09-12 02:24:21 +00:00 committed by GitHub
commit 3b18309cf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 642 additions and 0 deletions

View file

@ -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 (S1S14) 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)
```

View file

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

View file

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

View file

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

View 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"