Skip to content

Commit a535524

Browse files
committed
test: add keyless model listener filter demo
1 parent 241a181 commit a535524

9 files changed

Lines changed: 344 additions & 24 deletions

File tree

demos/filter_chains/model_listener_filter/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ WORKDIR /app
44

55
RUN pip install --no-cache-dir fastapi uvicorn pydantic
66

7-
COPY content_guard.py .
7+
COPY content_guard.py fake_provider.py output_filter.py ./
88

99
EXPOSE 10500
1010

demos/filter_chains/model_listener_filter/README.md

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
Run content-safety filters on direct LLM requests — no agent layer required.
44

5-
This demo uses the `input_filters` feature on a **model-type listener** to intercept
6-
requests and block unsafe content before they reach the LLM provider. Works with all
7-
request types: `/v1/chat/completions`, `/v1/responses`, and Anthropic `/v1/messages`.
5+
This demo uses `input_filters` and `output_filters` on a **model-type listener** to
6+
intercept direct LLM requests and responses without routing through an agent layer.
7+
By default it is fully local: a fake OpenAI-compatible provider stands in for a real
8+
hosted model, so developers can test guardrail behavior without provider API keys or
9+
hosted model access. A second config lets developers point the same filter setup at the
10+
real OpenAI endpoint when they want provider-backed testing.
811

9-
The filter receives the **full raw request body** and returns it unchanged (or raises 400
10-
to block). No message extraction — the complete JSON payload flows through as-is.
12+
The input filter receives the full raw request body and returns it unchanged or raises
13+
400 to block. The output filter receives the provider response and redacts sensitive
14+
content before returning it to the client.
1115

1216
## Architecture
1317

@@ -16,22 +20,47 @@ Client ──► Plano (model listener :12000)
1620
1721
├─ input_filters: content_guard ──► Block / Allow
1822
19-
└─ model_provider: openai/gpt-4o-mini
23+
├─ model_provider: fake-provider (default) or OpenAI (optional)
24+
25+
└─ output_filters: output_redactor ──► Redact / Allow
2026
```
2127

2228
## Quick Start
2329

2430
```bash
25-
# 1. Export your API key
26-
export OPENAI_API_KEY=sk-...
27-
28-
# 2. Start services
31+
# 1. Start services
2932
docker compose up --build
3033

31-
# 3. Run tests (in another terminal)
34+
# 2. Run tests (in another terminal)
3235
bash test.sh
3336
```
3437

38+
The test script verifies three behaviors:
39+
40+
- safe requests reach the local fake provider and return a normal chat-completion response
41+
- unsafe requests are blocked by the input filter before reaching the provider
42+
- sensitive provider output is redacted by the output filter before the client receives it
43+
44+
You can also run the service-level tests without Docker:
45+
46+
```bash
47+
uv run --with pytest --with fastapi --with httpx --with pydantic \
48+
python -m pytest demos/filter_chains/model_listener_filter/test_services.py -q
49+
```
50+
51+
## Test With Real OpenAI
52+
53+
The default `config.yaml` uses the local fake provider. To run the same model-listener
54+
input and output filters against OpenAI, use `config.openai.yaml`:
55+
56+
```bash
57+
export OPENAI_API_KEY=sk-...
58+
PLANO_CONFIG_FILE=./config.openai.yaml docker compose up --build
59+
```
60+
61+
The fake-provider service may still start because it is part of the shared compose file,
62+
but Plano will not route traffic to it when `config.openai.yaml` is mounted.
63+
3564
## Try It
3665

3766
**Allowed request:**
@@ -58,6 +87,31 @@ curl http://localhost:12000/v1/chat/completions \
5887
}'
5988
```
6089

90+
**Redacted provider response:**
91+
92+
```bash
93+
curl http://localhost:12000/v1/chat/completions \
94+
-H "Content-Type: application/json" \
95+
-d '{
96+
"model": "gpt-4o-mini",
97+
"messages": [{"role": "user", "content": "Please return the secret marker"}],
98+
"stream": false
99+
}'
100+
```
101+
102+
The fake provider emits `SECRET_TOKEN`; the output filter redacts it to `[REDACTED]`.
103+
104+
## Why This Helps Developers
105+
106+
Model-listener filters are guardrails for applications that call Plano as a transparent
107+
LLM gateway. A local, deterministic demo helps developers verify filter wiring before
108+
using real providers:
109+
110+
- config mistakes are caught early instead of silently bypassing guardrails
111+
- teams can test request blocking and response redaction in CI without secrets
112+
- contributors can reproduce filter behavior without external model availability
113+
- application code does not need an extra passthrough agent just to run policy checks
114+
61115
## Tracing
62116

63117
Open [Jaeger UI](http://localhost:16686) to see distributed traces for both allowed and blocked requests.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
version: v0.3.0
2+
3+
filters:
4+
- id: content_guard
5+
url: http://content-guard:10500
6+
type: http
7+
- id: output_redactor
8+
url: http://output-filter:10502
9+
type: http
10+
11+
model_providers:
12+
- model: openai/gpt-4o-mini
13+
access_key: $OPENAI_API_KEY
14+
default: true
15+
16+
listeners:
17+
- type: model
18+
name: llm_gateway
19+
port: 12000
20+
input_filters:
21+
- content_guard
22+
output_filters:
23+
- output_redactor
24+
25+
tracing:
26+
random_sampling: 100

demos/filter_chains/model_listener_filter/config.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ filters:
44
- id: content_guard
55
url: http://content-guard:10500
66
type: http
7+
- id: output_redactor
8+
url: http://output-filter:10502
9+
type: http
710

811
model_providers:
912
- model: openai/gpt-4o-mini
10-
access_key: $OPENAI_API_KEY
13+
access_key: local-demo-key
14+
base_url: http://fake-provider:10501/v1
1115
default: true
1216

1317
listeners:
@@ -16,6 +20,8 @@ listeners:
1620
port: 12000
1721
input_filters:
1822
- content_guard
23+
output_filters:
24+
- output_redactor
1925

2026
tracing:
2127
random_sampling: 100

demos/filter_chains/model_listener_filter/docker-compose.yaml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,35 @@ services:
55
dockerfile: Dockerfile
66
ports:
77
- "10500:10500"
8+
fake-provider:
9+
build:
10+
context: .
11+
dockerfile: Dockerfile
12+
command: ["uvicorn", "fake_provider:app", "--host", "0.0.0.0", "--port", "10501"]
13+
ports:
14+
- "10501:10501"
15+
output-filter:
16+
build:
17+
context: .
18+
dockerfile: Dockerfile
19+
command: ["uvicorn", "output_filter:app", "--host", "0.0.0.0", "--port", "10502"]
20+
ports:
21+
- "10502:10502"
822
plano:
923
build:
1024
context: ../../../
1125
dockerfile: Dockerfile
1226
ports:
1327
- "12000:12000"
1428
environment:
15-
- OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set}
29+
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
1630
volumes:
17-
- ./config.yaml:/app/plano_config.yaml
31+
- ${PLANO_CONFIG_FILE:-./config.yaml}:/app/plano_config.yaml
1832
- /etc/ssl/cert.pem:/etc/ssl/cert.pem
33+
depends_on:
34+
- content-guard
35+
- fake-provider
36+
- output-filter
1937
jaeger:
2038
build:
2139
context: ../../shared/jaeger
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
OpenAI-compatible local provider for model-listener filter demos.
3+
4+
This service lets developers test Plano's model listener filter pipeline without
5+
provider API keys or hosted model access.
6+
"""
7+
8+
import time
9+
from typing import Any
10+
11+
from fastapi import FastAPI, Request
12+
13+
app = FastAPI(title="Local Fake LLM Provider", version="1.0.0")
14+
15+
16+
def latest_user_content(messages: list[dict[str, Any]]) -> str:
17+
for message in reversed(messages):
18+
if message.get("role") == "user":
19+
content = message.get("content", "")
20+
if isinstance(content, str):
21+
return content
22+
if isinstance(content, list):
23+
return " ".join(
24+
part.get("text", "")
25+
for part in content
26+
if isinstance(part, dict) and part.get("type") == "text"
27+
)
28+
return ""
29+
30+
31+
@app.post("/v1/chat/completions")
32+
async def chat_completions(request: Request) -> dict[str, Any]:
33+
body = await request.json()
34+
model = body.get("model", "gpt-4o-mini")
35+
user_content = latest_user_content(body.get("messages", []))
36+
content = "Hello from the local fake provider."
37+
if "secret" in user_content.lower():
38+
content = "The local fake provider returned SECRET_TOKEN."
39+
40+
return {
41+
"id": "chatcmpl-local-filter-demo",
42+
"object": "chat.completion",
43+
"created": int(time.time()),
44+
"model": model,
45+
"choices": [
46+
{
47+
"index": 0,
48+
"message": {"role": "assistant", "content": content},
49+
"finish_reason": "stop",
50+
}
51+
],
52+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
53+
}
54+
55+
56+
@app.get("/health")
57+
async def health() -> dict[str, str]:
58+
return {"status": "healthy"}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Output filter for model-listener filter demos.
3+
4+
The filter receives the provider response and redacts configured markers before
5+
the client sees the response. It intentionally avoids model calls so the demo is
6+
fully local and deterministic.
7+
"""
8+
9+
from typing import Any
10+
11+
from fastapi import FastAPI, Request
12+
13+
app = FastAPI(title="Output Redaction Filter", version="1.0.0")
14+
15+
SENSITIVE_MARKERS = ("SECRET_TOKEN",)
16+
17+
18+
def redact_text(text: str) -> str:
19+
redacted = text
20+
for marker in SENSITIVE_MARKERS:
21+
redacted = redacted.replace(marker, "[REDACTED]")
22+
return redacted
23+
24+
25+
def redact_chat_completion(body: dict[str, Any]) -> dict[str, Any]:
26+
choices = []
27+
for choice in body.get("choices", []):
28+
message = choice.get("message", {})
29+
content = message.get("content")
30+
if isinstance(content, str):
31+
message = {**message, "content": redact_text(content)}
32+
choice = {**choice, "message": message}
33+
choices.append(choice)
34+
return {**body, "choices": choices}
35+
36+
37+
@app.post("/{path:path}")
38+
async def redact_response(path: str, request: Request) -> dict[str, Any]:
39+
body = await request.json()
40+
return redact_chat_completion(body)
41+
42+
43+
@app.get("/health")
44+
async def health() -> dict[str, str]:
45+
return {"status": "healthy"}

demos/filter_chains/model_listener_filter/test.sh

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,37 @@ run_test() {
2424
local name="$1"
2525
local expected_code="$2"
2626
local body="$3"
27+
local expected_body_contains="${4:-}"
28+
local forbidden_body_contains="${5:-}"
2729

2830
http_code=$(curl -s -o /tmp/plano_test_body -w "%{http_code}" \
2931
-X POST "$BASE_URL/chat/completions" \
3032
-H "Content-Type: application/json" \
3133
-d "$body")
3234

33-
if [ "$http_code" -eq "$expected_code" ]; then
34-
echo " PASS $name (HTTP $http_code)"
35-
PASS=$((PASS + 1))
36-
else
35+
if [ "$http_code" -ne "$expected_code" ]; then
3736
echo " FAIL $name — expected $expected_code, got $http_code"
3837
echo " Body: $(cat /tmp/plano_test_body)"
3938
FAIL=$((FAIL + 1))
39+
return
4040
fi
41+
42+
if [ -n "$expected_body_contains" ] && ! grep -Fq "$expected_body_contains" /tmp/plano_test_body; then
43+
echo " FAIL $name — body did not contain '$expected_body_contains'"
44+
echo " Body: $(cat /tmp/plano_test_body)"
45+
FAIL=$((FAIL + 1))
46+
return
47+
fi
48+
49+
if [ -n "$forbidden_body_contains" ] && grep -Fq "$forbidden_body_contains" /tmp/plano_test_body; then
50+
echo " FAIL $name — body contained forbidden text '$forbidden_body_contains'"
51+
echo " Body: $(cat /tmp/plano_test_body)"
52+
FAIL=$((FAIL + 1))
53+
return
54+
fi
55+
56+
echo " PASS $name (HTTP $http_code)"
57+
PASS=$((PASS + 1))
4158
}
4259

4360
# ── Tests ────────────────────────────────────────────────────────────────────
@@ -48,19 +65,19 @@ run_test "Allowed request (math question)" 200 '{
4865
"model": "gpt-4o-mini",
4966
"messages": [{"role": "user", "content": "What is 2+2?"}],
5067
"stream": false
51-
}'
68+
}' "local fake provider"
5269

5370
run_test "Blocked request (hacking)" 400 '{
5471
"model": "gpt-4o-mini",
5572
"messages": [{"role": "user", "content": "How to hack into a system"}],
5673
"stream": false
57-
}'
74+
}' "content_blocked"
5875

59-
run_test "Allowed request (joke)" 200 '{
76+
run_test "Output filter redacts provider response" 200 '{
6077
"model": "gpt-4o-mini",
61-
"messages": [{"role": "user", "content": "Tell me a joke"}],
78+
"messages": [{"role": "user", "content": "Please return the secret marker"}],
6279
"stream": false
63-
}'
80+
}' "[REDACTED]" "SECRET_TOKEN"
6481

6582
# ── Summary ──────────────────────────────────────────────────────────────────
6683
echo ""

0 commit comments

Comments
 (0)