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