Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Publish Docker image

on:
release:
types: [published]
workflow_dispatch:

jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/crp4222/privaite
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ EXPOSE 8400
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8400/health || exit 1

# config/privaite.yaml is the operator's own file (gitignored); a fresh clone
# only has the example. Fall back to it so the image runs out of the box.
CMD ["sh", "-c", "python -m privaite --config \"$([ -f /app/config/privaite.yaml ] && echo /app/config/privaite.yaml || echo /app/config/privaite.example.yaml)\""]
# Config selection, in order: a mounted config/privaite.yaml wins; otherwise, if
# OPENAI_API_KEY is set, the drop-in OpenAI config is used (docker run -e
# OPENAI_API_KEY=... just works); otherwise the local Ollama example config, so
# the image still boots out of the box.
CMD ["sh", "-c", "if [ -f /app/config/privaite.yaml ]; then CFG=/app/config/privaite.yaml; elif [ -n \"$OPENAI_API_KEY\" ]; then CFG=/app/config/privaite.openai.yaml; else CFG=/app/config/privaite.example.yaml; fi; exec python -m privaite --config \"$CFG\""]
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ Self-hosted PII redaction proxy for LLM APIs.
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License](https://img.shields.io/badge/license-BSD--3--Clause-green.svg)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/privaite.svg)](https://pypi.org/project/privaite/)
[![PyPI downloads](https://img.shields.io/pypi/dm/privaite.svg)](https://pypi.org/project/privaite/)

**A drop-in LLM proxy that reversibly replaces PII before it reaches the provider, including inside tool-call arguments and multimodal content, with zero telemetry.**

Expand Down Expand Up @@ -236,10 +235,48 @@ WebUI filter (see [Open WebUI filter](#open-webui-filter) below).

## Docker

Pull and run, no build needed. The detection model is baked into the image, so it
runs offline from the first request.

Proxying OpenAI, just set your key:

```bash
docker run -d -p 8400:8400 \
-e PRIVAITE_API_KEYS=change-me \
-e OPENAI_API_KEY=sk-... \
ghcr.io/crp4222/privaite
```

That exposes `gpt-4o-mini` and `gpt-4o`. For any other provider (Ollama, Azure, a
self-hosted endpoint, or your own LiteLLM proxy), mount a config instead:

```bash
docker compose up -d
docker run -d -p 8400:8400 \
-e PRIVAITE_API_KEYS=change-me \
-v $PWD/privaite.yaml:/app/config/privaite.yaml:ro \
ghcr.io/crp4222/privaite
```

A minimal `privaite.yaml`:

```yaml
providers:
- model_name: my-model
litellm_params:
model: openai/gpt-4o-mini # any litellm model string, e.g. ollama/llama3
api_base: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY}
pii:
enabled: true
preset: onnx
```

Auth is on by default, so `PRIVAITE_API_KEYS` is required. From a clone you can also
`docker compose up -d`; put the key in a `.env` file to keep it off the command line.

Then point any OpenAI-compatible client (or Open WebUI) at `http://localhost:8400/v1`
with the key `change-me`.

## Open WebUI filter

`integrations/openwebui/privaite_filter.py` is an Open WebUI Filter Function. It
Expand Down
103 changes: 103 additions & 0 deletions config/privaite.openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
server:
host: "0.0.0.0"
port: 8400
workers: 1
log_level: "info"

auth:
enabled: true

providers:
# Drop-in OpenAI: this config is selected automatically when OPENAI_API_KEY is
# set in the environment and no config/privaite.yaml is mounted. Add more models
# by copying a block. Uses the standard OpenAI provider via litellm.
- model_name: "gpt-4o-mini"
litellm_params:
model: "openai/gpt-4o-mini"
api_key: "${OPENAI_API_KEY}"

- model_name: "gpt-4o"
litellm_params:
model: "openai/gpt-4o"
api_key: "${OPENAI_API_KEY}"

pii:
enabled: true
# Full ONNX suite (default): ~84.5% recall on the benchmark (~749ms). Detects
# everything including secrets and passwords; downloads the ONNX model on first
# run. Use "light" for the fast Presidio-only path (~62% recall, near-zero
# latency). Do NOT pin detectors.presidio.entities below on the light path: it
# restricts detection to only those types and drops recall to ~35%.
preset: "onnx"

detectors:
presidio:
enabled: true
languages: ["fr", "en"]
score_threshold: 0.4
# entities: leave unset. The onnx preset automatically scopes Presidio to
# the types it is precise at (the ONNX model covers the rest). Only pin an
# allowlist for advanced tuning, and never on preset: "light".

# ONNX privacy-filter detector. This is what preset: "onnx" turns on (the
# default). The preset enables it automatically; uncomment to tune it.
# onnx:
# enabled: true
# model_name: "openai/privacy-filter"
# onnx_variant: "q4f16" # quantized variant downloaded from Hugging Face
# device: "auto" # auto, cpu, cuda, coreml/mps
# score_threshold: 0.5

bert_ner:
enabled: false
model_name: "dslim/bert-base-NER"
device: "auto"
score_threshold: 0.5

mlmodel:
enabled: false
model_name: "openai/privacy-filter"
device: "auto"
torch_dtype: "float16"
score_threshold: 0.5

merge_strategy: "union"
overlap_resolution: "highest_score"
on_error: "block"

# Hard policy gate: PII types listed here cause the WHOLE request to be
# rejected (HTTP 400) instead of being pseudonymized. Empty by default, so
# every detected type is masked and forwarded as usual. Opt-in per type.
block_entities: []
# block_entities: ["US_SSN", "CREDIT_CARD"]

anonymization:
method: "placeholder"
faker_locale: ["fr_FR", "en_US"]
entity_overrides:
CREDIT_CARD:
method: "mask"
masking_char: "*"
SECRET:
method: "redact"

deanonymization:
enabled: true
# Opt-in: also restore placeholders the model re-typed imperfectly (small
# wrong-substitution risk on lookalike spans).
fuzzy_matching: false
fuzzy_threshold: 0.85

# Dry-run inspection endpoint (POST /v1/pii/inspect): returns the detections
# for a text you submit, without forwarding anything to any provider and
# without logging any value. For testing what gets redacted. Off by default.
inspect:
enabled: false

passthrough:
system_messages: false
tool_calls: false

logging:
format: "json"
level: "info"