Configuration reference
Everything below goes in your privaite.yaml (python -m privaite --config privaite.yaml).
The README quick start runs the shipped
config/privaite.openai.yaml; this page covers every knob. The minimal working file is:
providers:
- model_name: gpt-4o-mini
litellm_params:
model: openai/gpt-4o-mini
api_key: ${OPENAI_API_KEY}
pii:
enabled: true
preset: onnx # or "light": faster, no model download, classic PII only
With that file every detected type gets a reversible placeholder. The shipped
configs add two per-type exceptions on top, a masked CREDIT_CARD and a redacted
SECRET: see entity overrides.
Unknown keys fail at boot
Every section is validated strictly: a key PrivAiTe does not know refuses startup and names the offending path, rather than being ignored.
ValueError: Invalid config privaite.yaml: pii.block_entites (unknown config key, check the spelling)
That typo used to start a proxy with an empty policy gate while the operator
believed those types were rejected, and a gateway: enable: typo used to leave
the gateway silently off. The single exception is litellm_params, which is
handed to LiteLLM as-is and therefore accepts any provider parameter.
Server
server:
host: "0.0.0.0" # default
port: 8400 # default
Both can also be overridden at launch: python -m privaite --config privaite.yaml --host 127.0.0.1 --port 8452.
LLM providers
Any LiteLLM-supported provider works:
providers:
- model_name: "gpt-4o"
litellm_params:
model: "openai/gpt-4o"
api_key: "${OPENAI_API_KEY}"
- model_name: "local-llama"
litellm_params:
model: "ollama/llama3.1"
api_base: "http://localhost:11434"
A ${VAR} whose environment variable is unset fails startup on purpose, so a
missing key is caught at boot rather than at the first request.
Request parameters
Since 0.7.3, the OpenAI-compatible endpoints forward the OpenAI parameters of each endpoint
(temperature, tools, response_format…) and answer anything else with a
400 unsupported_parameter that names the field (a field sent as null is
dropped instead). A field such as api_base or
extra_body is a call setting for LiteLLM, not a provider parameter: forwarded
from the client, it could send the request and your API key to another server,
or replace the anonymized messages. To pass a provider-specific field, list it
on that provider:
- model_name: "local-llama"
litellm_params:
model: "ollama/llama3.1"
api_base: "http://localhost:11434"
forward_params: ["top_k"]
These fields reach the provider unscanned. Call settings (api_base,
base_url, api_key, extra_body, extra_headers, the aws_, azure_ and
vertex_ families…) are refused at startup: set them in litellm_params.
Agent CLI gateway
Opt-in native routes for Claude Code (Anthropic Messages, validated live) and Codex (OpenAI Responses, beta). Gateway routes relay the client’s own provider credentials as-is; PrivAiTe injects and validates no key there. Enable the detection cache alongside it. Full setup, scanned surface and limits: gateway.md.
gateway:
enabled: false # default: off, the routes do not exist
anthropic:
base_url: "https://api.anthropic.com/v1"
openai_responses: # beta (Codex)
base_url: "https://api.openai.com/v1"
Docker with a custom config
With just OPENAI_API_KEY set, the image 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:
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
The image is published to both ghcr.io/crp4222/privaite and Docker Hub
(crp4222/privaite); either works in the commands above.
A minimal privaite.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.
Anonymization method
pii:
anonymization:
method: "placeholder" # <PERSON_1>, <EMAIL_ADDRESS_1> (recommended)
# method: "fake_replacement" # Realistic fakes via Faker (Jean → Michel)
# method: "redact" # [PERSON], [EMAIL_ADDRESS] (irreversible)
# method: "mask" # ******** (irreversible)
redact and mask are lossy on purpose: the original never enters the reversible
map, so nothing is restored in responses for those types (and two values that mask
to the same string can never cross-restore).
Placeholder-shaped text already in the request (a <PERSON_1> in a test fixture,
a template, a tool definition or the agent’s system prompt) is never issued as a
placeholder: numbering skips it, so the provider never sees one placeholder with
two meanings and the restore never writes a real value into it. When such a
string only appears after its number went to a detected value (in a later
message), it is sent as a fresh placeholder of its own and restored to itself.
Helping an agent preserve placeholders
Restoration matches the actual placeholder, not the model’s interpretation of it.
If the model invents a plausible email instead of copying <EMAIL_ADDRESS_1>,
there is no matching value to restore. A cooperative agent can use these
instructions in its system/developer prompt (also available as a
copyable English file):
Preserve privacy placeholders exactly, including in tool arguments and structured output. Do not rename them, substitute plausible examples, or infer their original values. Keep the surrounding output format valid. Treat
[SECRET]and masked values as unrecoverable. State when required information is missing.
PrivAiTe does not inject this prompt automatically or change agent permissions. The instruction improves response fidelity; it does not enforce confidentiality or make an untrusted endpoint obey. If a lab endpoint replaces client system messages, place the instruction in the effective operator prompt for a cooperative test, or explicitly test the behavior without it.
Incorrect detection boundaries are a separate issue: copying a placeholder exactly
also restores any extra characters mistakenly included in its original value.
The built-in detectors now preserve matched surrounding quotes/angle brackets for
emails, phones and URLs, common email assignment labels (including line-numbered
tool results), and the <path> wrapper around a detected path. Refinement happens
before overlapping detections are merged. A model span on a .env line is
fitted to the value: the upper-case variable name, the : and @ around a URI
password and the following line are given back
(details).
Punctuation inside a secret and explicit custom-pattern boundaries are not
trimmed. Path contents are still scanned, and false positives or other
over-broad spans remain possible.
Entity overrides (per-type methods)
anonymization.method is the default for every type; entity_overrides changes
it for named types. Both shipped configs use this, and it is the one place
where PrivAiTe is deliberately not reversible:
pii:
anonymization:
method: "placeholder"
entity_overrides:
CREDIT_CARD:
method: "mask" # ****************, irreversible
masking_char: "*"
SECRET:
method: "redact" # [SECRET], irreversible
That block is what ships in config/privaite.example.yaml and
config/privaite.openai.yaml, and the Docker image picks one of those two when
you do not mount your own config, so the documented Docker quickstart runs
with card numbers masked and secrets redacted. Concretely: a card number or a
secret detected in your request is destroyed on the way out, the provider sees
**************** or [SECRET], and the reply comes back with that stand-in
still in place. It is never restored, because the original was never kept. Every
other type keeps the reversible placeholder method and does come back.
This is the safer default for the two types whose leak is worst, and it is a
choice, not a law. Delete the entity_overrides block (or set those types to
placeholder) if you would rather have card numbers and secrets restored in the
reply; keep it, or extend it to more types, if you would rather they never come
back at all. Each override takes method (placeholder, fake_replacement,
redact, mask) and, for mask, masking_char.
Since 0.5.0: when entity types overlap, block_entities takes
precedence, then irreversible methods (redact/mask), then the configured
overlap resolution. This prevents a higher-confidence EMAIL span from making
an overlapping redacted SECRET reversible. All detected characters remain
covered by the merged span. Both redact and mask have equal priority; neither
can be restored. The detection cache includes the policy in its fingerprint.
An override is about how a type is replaced. If a type must not be sent at
all, even as a stand-in, use block_entities
instead: that rejects the whole request.
Blocking specific PII types (hard policy gate)
By default every detected PII item is pseudonymized and the request goes through.
If some PII types must never leave your network at all, even as a placeholder,
list them under block_entities. A request containing any listed type is rejected
with 400 and nothing is forwarded to the provider. The error names the type(s),
never the value.
pii:
block_entities: [] # default: block nothing, mask everything
# block_entities: ["US_SSN", "CREDIT_CARD"] # opt-in: reject these outright
Types not listed are still masked as usual, so blocking is purely additive on top of the default behavior.
The proxy refuses to start if a listed type cannot be emitted by any enabled
detector (for example US_PASSPORT under the default onnx preset): a block
rule that can never fire would be silently unenforceable. Fix it by removing the
type or enabling a detector that produces it (a label_mapping value, a Presidio
entity, or a custom_patterns entity type).
Detection cache (agent sessions)
Repeated ONNX windows within one request
The ONNX detector reuses predictions for identical model input windows within
one scrub operation by default (pii.detectors.onnx.deduplicate_windows: true).
Small changes to a log header no longer force identical windows farther down that
log to be evaluated again when several tool results appear in the same request.
The model, window size, overlap, thresholds and scan coverage stay the same.
This is separate from the opt-in cache below. Each outer engine or gateway scrub call gets its own bounded cache (128 windows). Keys are salted hashes of the session identity and the exact input tensors, including their shapes and masks. Only per-token detection labels and confidence scores are kept. Text, input token IDs, logits, original offsets and reversible maps are not cached. Offsets are taken from the current text. The cache is cleared at the end of the operation, including on errors and cancellation; a late worker cannot repopulate it.
In-process integrations inherit this through their engine calls. Separate engine
calls outside a shared scope do not retain predictions between them. Concurrent
requests have separate caches. This optimization helps repeated content, not a
stream of entirely new windows; set deduplicate_windows: false for an A/B check.
Repeated text across requests
Agent CLIs (Claude Code, Codex) resend the entire growing conversation on every
turn, so the proxy re-scans mostly identical bytes: O(n) detector work per turn,
O(n^2) per session. The detection cache remembers the merged detection result
for each exact text leaf, so a resent leaf skips the detectors entirely. On a
real captured 11-turn Codex session with the onnx preset, per-turn scrub time
drops to well under a second from turn 2 on, with byte-identical output.
pii:
detection_cache:
enabled: false # default: off (see the threat model)
max_entries: 4096 # LRU bound
ttl_seconds: 1800 # entries expire after 30 minutes (swept on the next write)
What it stores and what it never stores:
- Stored: salted BLAKE2b hashes of scanned text leaves (the salt is random per process and never leaves it), plus span metadata (start, end, entity type, score, detector source) for each hash.
- Never stored: the text itself, the matched PII values, the anonymized output, or any placeholder mapping. Placeholder numbering stays per-request.
The block_entities gate and the anonymizer run on every request, cached or
not, so a cached detection still blocks and still fails closed. A change to any
detector setting changes the cache key fingerprint, so stale results are never
served after a config change.
Why it is off by default: with the cache enabled, PII-derived metadata (hashes,
positions, types) survives in process memory up to ttl_seconds after a
request ends, instead of nothing outliving the request. An expired entry is
never served again; it is removed from memory on the first cache write after
its expiry, and the whole cache is cleared at engine shutdown, so only a
process that goes completely idle holds its last (expired, unusable) entries
longer, until that next write or shutdown. The full delta,
including the multi-user dedup timing side channel, is spelled out in the
threat model. Enable it if you use the
agent CLI gateway or any client that resends conversation
history; leave it off if the stricter memory posture matters more than latency.
Custom regex patterns
Add your own PII patterns without touching code:
pii:
custom_patterns:
- pattern: "KD-\\d{6}"
entity_type: "CUSTOMER_ID"
- pattern: "REF-[A-Z]{3}-\\d+"
entity_type: "REFERENCE"
By default the whole match is anonymized. When the pattern needs context that must stay readable, name the part that carries the value: the anonymized span is then the named group alone, and the surrounding text is left untouched.
pii:
custom_patterns:
# "api_key=SECRETVALUE" becomes "api_key=<SECRET_1>", not "<SECRET_1>"
- pattern: "api_key=(?P<value>[A-Za-z0-9_\\-]{16,})"
entity_type: "SECRET"
value is the conventional name; with several named groups it wins, otherwise
the first declared group is used. Unnamed (...) groups are not markers, so a
pattern without a named group keeps anonymizing the whole match.
Custom patterns are an explicit opt-in: their entity types are never filtered out by a preset’s Presidio entity allowlist.
Languages
7 languages supported with spaCy NER and contextual patterns: FR, EN, DE, ES, IT (benchmarked), plus PT and NL (best-effort, not yet in the benchmark).
pii:
detectors:
presidio:
languages: ["fr", "en"] # the default; add "de", "es", etc.
Each language needs its spaCy model: python -m spacy download de_core_news_md.
The default list is ["fr", "en"], so a fresh install fetches fr_core_news_md
on first boot if it is missing; set languages: ["en"] for an English-only,
no-surprise-download setup.
The order matters. Pattern recognizers and the contextual rules run for every
listed language, but spaCy’s statistical NER is kept only for the first one:
a model reading text in another language produced false positives, such as
“résumer” taken for a person. Put the language of most of your traffic first.
With the default ["fr", "en"], English text gets no spaCy names; the onnx
preset finds them with its own model, light does not (“I am Marie Dupont”
is missed there). An English-first list fixes that for light, at the cost of
the English model’s guesses on French text (“Peux-tu” or “Python” as
organizations).
Built-in recognizers
On top of Presidio’s own recognizers, PrivAiTe registers a few of its own: contextual names, contextual locations, structured secrets and dates. Two things about them are worth knowing, because neither is obvious from the config:
The entities allowlist does not scope them. It scopes Presidio’s own
recognizers to the types the preset trusts them for; ours are exempt, otherwise
a preset allowlist that happens to omit their type (the location recognizer
emits LOCATION, the secret one emits SECRET) would register them on every
analyzer and filter them out of every result, silently. So removing a type from
entities does not stop a built-in recognizer emitting it. Their types are
named in a warning at startup when they fall outside the allowlist.
Language-specific vocabulary follows the language. The date recognizer
carries French and German month names and applies each only to that language.
Applying both to every configured language is how an English or Dutch
deployment used to see 11 September and 11 April masked while 11 October
and 11 March went through: the masked ones are exactly the months spelled like
the German ones. Its numeric, birth-word pattern (born 15/03/1987) carries no
month vocabulary and stays active for every language.
To switch one off, name it:
pii:
detectors:
presidio:
disabled_recognizers: ["FrenchDateRecognizer"]
Accepted names: ContextualNameRecognizer, FrenchDateRecognizer,
ContextualLocationRecognizer, StructuredSecretRecognizer. The list is empty
by default, so secret and contextual detection keep working unless you say
otherwise. Disabling one also narrows what block_entities considers
enforceable, so a rule that only that recognizer could satisfy is refused at
boot rather than never firing. An unrecognized name is refused when the config
loads, so a typo cannot leave you believing a recognizer is off while it is
still masking. Disabling StructuredSecretRecognizer also turns off the
propagation of the credentials it identifies (see
detection).
These recognizers carry vocabulary, and vocabulary follows the language they are
built for: a French deployment gets the French cues, an Italian one the Italian
cues. Configure every language your traffic actually uses (the default is
["fr", "en"]), because a cue written in a language you did not configure will
not fire.
Detector model revisions
The built-in Hugging Face detector models are pinned to immutable commits, so a
fresh install does not silently pick up different weights from a moving main
branch:
openai/privacy-filter:7ffa9a043d54d1be65afb281eddf0ffbe629385b(the default ONNX detector and the optional torch detector)dslim/bert-base-NER:d1a3e8f13f8c3566299d95fcfc9a8d2382a9affcurchade/gliner_multi_pii-v1:1fcf13e85f4eef5394e1fcd406cf2ca9ea82351dcrp4222/privaite-pii-minilm:e7ea9dd38cd6d03198aeba57174a1d6d3a95618d(the opt-inminilmandultrafastpresets)
When changing a detector’s model_name, also set revision to a commit SHA
from that model’s repository. A default SHA from a different repository fails to
load instead of silently using different weights. revision: null intentionally
follows the mutable default branch and is not reproducible.
ONNX variant
The onnx preset runs the Privacy Filter through ONNX Runtime, and
onnx_variant defaults to q4. The other supported choice is q4f16. Both
hold the same 4-bit weights: q4 computes in fp32 and q4f16 in fp16, which
CPUs do not run natively, so q4 is the faster one on CPU and q4f16 is the
one to try on a GPU session. The measured difference is in
detection.
pii:
preset: onnx
detectors:
onnx:
onnx_variant: q4f16 # default: q4
Only the configured variant is downloaded: about 917 MB for q4, 809 MB for
q4f16. An install upgraded from a release that defaulted to q4f16 downloads
q4 once on its first start; set onnx_variant: q4f16 to keep using the model
already in the cache. A host with no network access has to do one or the other
before upgrading: fetch q4 into its Hugging Face cache, or pin q4f16.
Without either, the proxy refuses to start rather than run without its
detector. The Docker image bakes the default. To bake the other one,
build with --build-arg ONNX_VARIANT=q4f16 and set the same onnx_variant in
the mounted config, otherwise the first start downloads the default.
PrivAiTe MiniLM model (opt-in)
Two presets swap the Privacy Filter for PrivAiTe’s own model,
crp4222/privaite-pii-minilm:
a multilingual MiniLM trained for this proxy, half the Privacy Filter’s
download (a quarter under ultrafast) and several times faster, on CPU and more
so on the Apple Neural Engine. Nothing changes unless you set one of them: the default stays
onnx. What they detect and how they score is in
detection.
pii:
preset: minilm # Presidio (spaCy) + the MiniLM model on CPU, any platform
# preset: ultrafast # the MiniLM model on the Neural Engine + Presidio without spaCy (macOS)
| Preset | Presidio | Model | Extra install |
|---|---|---|---|
minilm |
with its spaCy models, as under onnx |
ONNX fp32 on CPU | none |
ultrafast |
blank spaCy pipelines (nlp_engine: blank) |
Core ML on the Neural Engine | pip install 'privaite[ane]', macOS only; no spaCy model to download |
Both keep the Presidio allowlist, the structured-secret rules and every other
setting of onnx. ultrafast is meant for agent CLIs on a Mac (Claude Code,
Codex through the gateway), which resend the whole
conversation every turn. Off macOS, or without coremltools, it refuses to
start and names the fix.
Each part can also be chosen on its own, and an explicit value wins over the preset:
pii:
preset: ultrafast
detectors:
minilm:
backend: onnx_int8 # onnx (default), onnx_int8, coreml
score_threshold: 0.0 # the evaluated argmax decoding; raise to mask less
# label_mapping: drop a type to stop masking it, rename one to change its placeholder
presidio:
nlp_engine: spacy # spacy (default), blank
backend: onnx is the fp32 export and gives exactly the trained model’s
spans; onnx_int8 is 4 times smaller and about twice as fast on CPU, for a
point of recall or less; coreml runs on the Neural Engine.
nlp_engine: blank loads no spaCy model: the pattern recognizers and
PrivAiTe’s own recognizers still run, but spaCy’s named entities are gone, so a
name with no cue around it is left to the model. It is the faster choice on
long requests, and the one to avoid with preset: light, which has no model to
fall back on.
The model’s types are PERSON, CONTACT (emails, phones, URLs, user names),
LOCATION (addresses and places), ORGANIZATION, DATE_TIME, AGE,
DEMOGRAPHIC, GOV_ID, FINANCIAL, NETWORK_ID, RECORD_ID, SECRET and
OTHER. Presidio still emits its own types for the same values (an email is
EMAIL_ADDRESS there), and a type listed in block_entities wins any overlap,
so blocking EMAIL_ADDRESS keeps working; list the model’s types (GOV_ID,
CONTACT…) to block what only the model finds.
Detector device
device selects the accelerator per detector. An unknown value is refused at
boot, naming the offending key: onnxruntime does not fail when an execution
provider is missing (it warns and builds a CPU session), so a typo, or cuda on
a CPU-only build, used to look like a working accelerator. The startup log now
reports the providers the session really runs on, and warns when a requested one
is unavailable.
| Detector | Accepted values |
|---|---|
onnx |
auto, cpu, cuda, coreml, mps (execution provider names, no index) |
minilm |
no device: backend picks the runtime (onnx and onnx_int8 on CPU, coreml on the Neural Engine) |
mlmodel, bert_ner, gliner |
auto, cpu, cuda, mps, with an optional index such as cuda:1 |
auto never selects CoreML for the ONNX detector: it is slower than CPU at every
measured input size and accumulates compiled-model memory until the host process
is killed. device: "coreml" stays available as an explicit opt-in.
Outbound connections
The only request PrivAiTe makes on your behalf is the one to your provider.
Two downloads happen once, on a first run outside Docker: the detection model
from the Hugging Face Hub (about 900 MB; about 490 MB under minilm and 250 MB
under ultrafast) and any missing spaCy language model. The Docker image
carries the default model and spaCy models; it carries the MiniLM model only
when built with --build-arg MINILM=onnx (or onnx_int8).
Several dependencies contact a third party on their own, and since 0.7.2 PrivAiTe turns each of them off (on an earlier version, export the variables below yourself):
| Dependency | What it did by default | How it is turned off |
|---|---|---|
| ONNX Runtime 1.29 and later | Uploads telemetry (runtime, hardware and model metadata, a hashed device identifier) to mobile.events.data.microsoft.com |
ORT_DISABLE_TELEMETRY=1, and disable_telemetry_events() when the session is created |
| LiteLLM | Fetches its price table from raw.githubusercontent.com at import |
LITELLM_LOCAL_MODEL_COST_MAP=True (the bundled table is used) |
| Hugging Face Hub | Asks the Hub about model files on every start, cached or not | A pinned revision is read from the cache without a request; HF_HUB_DISABLE_TELEMETRY=1 |
| tldextract (used by Presidio) | Downloads the public suffix list from publicsuffix.org |
Its bundled list is used |
None of these carried request text. The three environment variables are set
when privaite is imported, before the libraries read them, and the Docker
image sets them too. A value you export yourself is kept, so
LITELLM_LOCAL_MODEL_COST_MAP=False brings the remote price table back.
Two limits. A host process that loaded one of these libraries before importing
privaite (Open WebUI with the filter, a LiteLLM proxy with the guardrail) has
already read its environment: set the variables on that process. And an
unpinned revision (a branch name) still asks the Hub what it points at; set
HF_HUB_OFFLINE=1 for a process that must never do that.