Skip to content

Commit 92e764a

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

10 files changed

Lines changed: 499 additions & 26 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: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,30 @@
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`.
8-
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.
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.
11+
The filter pattern applies to OpenAI Chat Completions (`/v1/chat/completions`),
12+
OpenAI Responses (`/v1/responses`), and Anthropic Messages (`/v1/messages`) request
13+
shapes. The keyless fake provider and smoke test use `/v1/chat/completions` for a
14+
deterministic local path.
15+
16+
The input filter receives the full raw request body and returns it unchanged or raises
17+
400 to block. The output filter receives the provider response and redacts sensitive
18+
content before returning it to the client.
19+
20+
## Files
21+
22+
- `config.yaml` runs the default keyless path with the local fake provider.
23+
- `config.openai.yaml` runs the same filters against OpenAI.
24+
- `docker-compose.yaml` starts the local demo without requiring provider credentials.
25+
- `docker-compose.openai.yaml` mounts `config.openai.yaml` and requires `OPENAI_API_KEY`
26+
for provider-backed testing.
27+
- `test.sh` runs the Docker smoke test through Plano.
28+
- `test_services.py` runs service-level regression tests without Docker.
1129

1230
## Architecture
1331

@@ -16,22 +34,82 @@ Client ──► Plano (model listener :12000)
1634
1735
├─ input_filters: content_guard ──► Block / Allow
1836
19-
└─ model_provider: openai/gpt-4o-mini
37+
├─ model_provider: fake-provider (default) or OpenAI (optional)
38+
39+
└─ output_filters: output_redactor ──► Redact / Allow
2040
```
2141

2242
## Quick Start
2343

2444
```bash
25-
# 1. Export your API key
26-
export OPENAI_API_KEY=sk-...
27-
28-
# 2. Start services
45+
# 1. Start services
2946
docker compose up --build
3047

31-
# 3. Run tests (in another terminal)
48+
# 2. Run tests (in another terminal)
49+
bash test.sh
50+
```
51+
52+
The test script verifies three behaviors:
53+
54+
- safe requests reach the local fake provider and return a normal chat-completion response
55+
- unsafe requests are blocked by the input filter before reaching the provider
56+
- sensitive provider output is redacted by the output filter before the client receives it
57+
58+
You can also run the service-level tests without Docker:
59+
60+
```bash
61+
uv run --with pytest --with fastapi --with httpx --with pydantic \
62+
python -m pytest demos/filter_chains/model_listener_filter/test_services.py -q
63+
```
64+
65+
## Validate Locally
66+
67+
From this directory, validate the default keyless compose path:
68+
69+
```bash
70+
docker compose config
71+
```
72+
73+
Validate that the OpenAI path fails early when the API key is missing:
74+
75+
```bash
76+
docker compose -f docker-compose.yaml -f docker-compose.openai.yaml config
77+
```
78+
79+
Expected error:
80+
81+
```text
82+
OPENAI_API_KEY environment variable is required but not set
83+
```
84+
85+
Then confirm the OpenAI compose path renders when a key is provided:
86+
87+
```bash
88+
OPENAI_API_KEY=dummy docker compose -f docker-compose.yaml -f docker-compose.openai.yaml config
89+
```
90+
91+
Run the full local smoke test:
92+
93+
```bash
94+
docker compose down
95+
docker compose up --build -d
3296
bash test.sh
97+
docker compose down
3398
```
3499

100+
## Test With Real OpenAI
101+
102+
The default `config.yaml` uses the local fake provider. To run the same model-listener
103+
input and output filters against OpenAI, use the OpenAI compose override:
104+
105+
```bash
106+
export OPENAI_API_KEY=sk-...
107+
docker compose -f docker-compose.yaml -f docker-compose.openai.yaml up --build
108+
```
109+
110+
The fake-provider service may still start because it is part of the shared compose file,
111+
but Plano will not route traffic to it when `config.openai.yaml` is mounted.
112+
35113
## Try It
36114

37115
**Allowed request:**
@@ -58,6 +136,31 @@ curl http://localhost:12000/v1/chat/completions \
58136
}'
59137
```
60138

139+
**Redacted provider response:**
140+
141+
```bash
142+
curl http://localhost:12000/v1/chat/completions \
143+
-H "Content-Type: application/json" \
144+
-d '{
145+
"model": "gpt-4o-mini",
146+
"messages": [{"role": "user", "content": "Please return the secret marker"}],
147+
"stream": false
148+
}'
149+
```
150+
151+
The fake provider emits `SECRET_TOKEN`; the output filter redacts it to `[REDACTED]`.
152+
153+
## Why This Helps Developers
154+
155+
Model-listener filters are guardrails for applications that call Plano as a transparent
156+
LLM gateway. A local, deterministic demo helps developers verify filter wiring before
157+
using real providers:
158+
159+
- config mistakes are caught early instead of silently bypassing guardrails
160+
- teams can test request blocking and response redaction in CI without secrets
161+
- contributors can reproduce filter behavior without external model availability
162+
- application code does not need an extra passthrough agent just to run policy checks
163+
61164
## Tracing
62165

63166
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
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
plano:
3+
environment:
4+
OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set}
5+
volumes:
6+
- ./config.openai.yaml:/app/plano_config.yaml

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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 json
9+
import time
10+
from typing import Any
11+
12+
from fastapi import FastAPI, Request
13+
from fastapi.responses import Response, StreamingResponse
14+
15+
app = FastAPI(title="Local Fake LLM Provider", version="1.0.0")
16+
17+
18+
def latest_user_content(messages: list[dict[str, Any]]) -> str:
19+
for message in reversed(messages):
20+
if message.get("role") == "user":
21+
content = message.get("content", "")
22+
if isinstance(content, str):
23+
return content
24+
if isinstance(content, list):
25+
return " ".join(
26+
part.get("text", "")
27+
for part in content
28+
if isinstance(part, dict) and part.get("type") == "text"
29+
)
30+
return ""
31+
32+
33+
@app.post("/v1/chat/completions", response_model=None)
34+
async def chat_completions(request: Request) -> dict[str, Any] | Response:
35+
body = await request.json()
36+
model = body.get("model", "gpt-4o-mini")
37+
user_content = latest_user_content(body.get("messages", []))
38+
content = "Hello from the local fake provider."
39+
if "secret" in user_content.lower():
40+
content = "The local fake provider returned SECRET_TOKEN."
41+
42+
if body.get("stream") is True:
43+
async def generate():
44+
chunk = {
45+
"id": "chatcmpl-local-filter-demo",
46+
"object": "chat.completion.chunk",
47+
"created": int(time.time()),
48+
"model": model,
49+
"choices": [
50+
{
51+
"index": 0,
52+
"delta": {"role": "assistant", "content": content},
53+
"finish_reason": None,
54+
}
55+
],
56+
}
57+
yield f"data: {json.dumps(chunk)}\n\n"
58+
yield "data: [DONE]\n\n"
59+
60+
return StreamingResponse(generate(), media_type="text/event-stream")
61+
62+
return {
63+
"id": "chatcmpl-local-filter-demo",
64+
"object": "chat.completion",
65+
"created": int(time.time()),
66+
"model": model,
67+
"choices": [
68+
{
69+
"index": 0,
70+
"message": {"role": "assistant", "content": content},
71+
"finish_reason": "stop",
72+
}
73+
],
74+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
75+
}
76+
77+
78+
@app.get("/health")
79+
async def health() -> dict[str, str]:
80+
return {"status": "healthy"}

0 commit comments

Comments
 (0)