diff --git a/demos/filter_chains/model_listener_filter/Dockerfile b/demos/filter_chains/model_listener_filter/Dockerfile new file mode 100644 index 00000000..a9cc8bb6 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/Dockerfile @@ -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"] diff --git a/demos/filter_chains/model_listener_filter/README.md b/demos/filter_chains/model_listener_filter/README.md new file mode 100644 index 00000000..35be791d --- /dev/null +++ b/demos/filter_chains/model_listener_filter/README.md @@ -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. diff --git a/demos/filter_chains/model_listener_filter/config.yaml b/demos/filter_chains/model_listener_filter/config.yaml new file mode 100644 index 00000000..6d5bd1c6 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/config.yaml @@ -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 diff --git a/demos/filter_chains/model_listener_filter/content_guard.py b/demos/filter_chains/model_listener_filter/content_guard.py new file mode 100644 index 00000000..b452b10d --- /dev/null +++ b/demos/filter_chains/model_listener_filter/content_guard.py @@ -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"} diff --git a/demos/filter_chains/model_listener_filter/docker-compose.yaml b/demos/filter_chains/model_listener_filter/docker-compose.yaml new file mode 100644 index 00000000..9a755f88 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/docker-compose.yaml @@ -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" diff --git a/demos/filter_chains/model_listener_filter/test.sh b/demos/filter_chains/model_listener_filter/test.sh new file mode 100755 index 00000000..1729cb6a --- /dev/null +++ b/demos/filter_chains/model_listener_filter/test.sh @@ -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