mirror of
https://github.com/katanemo/plano.git
synced 2026-05-18 13:45:15 +02:00
add model listener filter chain demo
This commit is contained in:
parent
8136d7d6ab
commit
3d2be4f8b7
6 changed files with 272 additions and 0 deletions
11
demos/filter_chains/model_listener_filter/Dockerfile
Normal file
11
demos/filter_chains/model_listener_filter/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir fastapi uvicorn pydantic
|
||||
|
||||
COPY content_guard.py .
|
||||
|
||||
EXPOSE 10500
|
||||
|
||||
CMD ["uvicorn", "content_guard:app", "--host", "0.0.0.0", "--port", "10500"]
|
||||
59
demos/filter_chains/model_listener_filter/README.md
Normal file
59
demos/filter_chains/model_listener_filter/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Model Listener Filter Chain Demo
|
||||
|
||||
Run content-safety filters on direct LLM requests — no agent layer required.
|
||||
|
||||
This demo uses the `filter_chain` feature on a **model-type listener** to intercept
|
||||
`/v1/chat/completions` requests and block unsafe content before they reach the LLM provider.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client ──► Plano (model listener :12000)
|
||||
│
|
||||
├─ filter_chain: content_guard ──► Block / Allow
|
||||
│
|
||||
└─ model_provider: openai/gpt-4o-mini
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Export your API key
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# 2. Start services
|
||||
docker compose up --build
|
||||
|
||||
# 3. Run tests (in another terminal)
|
||||
bash test.sh
|
||||
```
|
||||
|
||||
## Try It
|
||||
|
||||
**Allowed request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:12000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "What is 2+2?"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
**Blocked request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:12000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "How to hack into a system"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
## Tracing
|
||||
|
||||
Open [Jaeger UI](http://localhost:16686) to see distributed traces for both allowed and blocked requests.
|
||||
21
demos/filter_chains/model_listener_filter/config.yaml
Normal file
21
demos/filter_chains/model_listener_filter/config.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
version: v0.3.0
|
||||
|
||||
filters:
|
||||
- id: content_guard
|
||||
url: http://content-guard:10500
|
||||
type: http
|
||||
|
||||
model_providers:
|
||||
- model: openai/gpt-4o-mini
|
||||
access_key: $OPENAI_API_KEY
|
||||
default: true
|
||||
|
||||
listeners:
|
||||
- type: model
|
||||
name: llm_gateway
|
||||
port: 12000
|
||||
filter_chain:
|
||||
- content_guard
|
||||
|
||||
tracing:
|
||||
random_sampling: 100
|
||||
84
demos/filter_chains/model_listener_filter/content_guard.py
Normal file
84
demos/filter_chains/model_listener_filter/content_guard.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Content guard filter — keyword-based content safety for model listeners.
|
||||
|
||||
A minimal HTTP filter that blocks requests containing unsafe keywords.
|
||||
No LLM calls required — keeps the demo self-contained and fast.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - [CONTENT_GUARD] - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Content Guard", version="1.0.0")
|
||||
|
||||
BLOCKED_KEYWORDS = [
|
||||
"hack",
|
||||
"exploit",
|
||||
"attack",
|
||||
"malware",
|
||||
"phishing",
|
||||
"ransomware",
|
||||
"ddos",
|
||||
"injection",
|
||||
"brute force",
|
||||
"keylogger",
|
||||
"bypass security",
|
||||
"steal credentials",
|
||||
"social engineering",
|
||||
]
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
def check_content(text: str) -> str | None:
|
||||
"""Return the matched keyword if blocked, else None."""
|
||||
lower = text.lower()
|
||||
for kw in BLOCKED_KEYWORDS:
|
||||
if kw in lower:
|
||||
return kw
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def content_guard(
|
||||
messages: List[ChatMessage], request: Request
|
||||
) -> List[ChatMessage]:
|
||||
"""Block messages that contain unsafe keywords."""
|
||||
last_user_msg = None
|
||||
for msg in reversed(messages):
|
||||
if msg.role == "user":
|
||||
last_user_msg = msg.content
|
||||
break
|
||||
|
||||
if last_user_msg is None:
|
||||
return messages
|
||||
|
||||
matched = check_content(last_user_msg)
|
||||
if matched:
|
||||
logger.warning(f"Blocked request — matched keyword: '{matched}'")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "content_blocked",
|
||||
"message": f"Request blocked by content safety filter (matched: '{matched}').",
|
||||
},
|
||||
)
|
||||
|
||||
logger.info("Content check passed — forwarding request")
|
||||
return messages
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
services:
|
||||
content-guard:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "10500:10500"
|
||||
plano:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "12000:12000"
|
||||
environment:
|
||||
- PLANO_CONFIG_PATH=/config/config.yaml
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set}
|
||||
volumes:
|
||||
- ./config.yaml:/app/plano_config.yaml
|
||||
- /etc/ssl/cert.pem:/etc/ssl/cert.pem
|
||||
jaeger:
|
||||
build:
|
||||
context: ../../shared/jaeger
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
71
demos/filter_chains/model_listener_filter/test.sh
Executable file
71
demos/filter_chains/model_listener_filter/test.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://localhost:12000/v1"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# ── Wait for Plano to be ready ──────────────────────────────────────────────
|
||||
echo "Waiting for Plano to be ready..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$BASE_URL/models" > /dev/null 2>&1; then
|
||||
echo "Plano is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: Plano did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ── Helper ───────────────────────────────────────────────────────────────────
|
||||
run_test() {
|
||||
local name="$1"
|
||||
local expected_code="$2"
|
||||
local body="$3"
|
||||
|
||||
http_code=$(curl -s -o /tmp/plano_test_body -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body")
|
||||
|
||||
if [ "$http_code" -eq "$expected_code" ]; then
|
||||
echo " PASS $name (HTTP $http_code)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $name — expected $expected_code, got $http_code"
|
||||
echo " Body: $(cat /tmp/plano_test_body)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Running tests..."
|
||||
|
||||
run_test "Allowed request (math question)" 200 '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "What is 2+2?"}],
|
||||
"stream": false
|
||||
}'
|
||||
|
||||
run_test "Blocked request (hacking)" 400 '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "How to hack into a system"}],
|
||||
"stream": false
|
||||
}'
|
||||
|
||||
run_test "Allowed request (joke)" 200 '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "Tell me a joke"}],
|
||||
"stream": false
|
||||
}'
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue