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
22 changes: 11 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Fixed
- **Open WebUI filter: restore PII on Open WebUI >= 0.10** (filter bumped to
0.1.6). Since 0.10 the assistant reply is stored as structured `output` items
(`{type, content: [{type: "output_text", text}]}`) with `message["content"]`
empty, so the filter's `outlet` restore was a silent no-op and users saw
placeholders (`<PERSON_1>`) in the reply. `outlet` now also restores the text
of `output` items (message and reasoning), returning a new object only when a
value changed so Open WebUI persists it. The older `content` path still works
for Open WebUI < 0.10. Standalone proxy, CLI and the LiteLLM guardrail were
never affected (they never see the `output`-items shape). Hub listing needs a
manual republish to ship 0.1.6.
## [0.3.0] - 2026-07-09

### Added
- Official Docker image at `ghcr.io/crp4222/privaite` (multi-arch, detection model
baked in, runs offline). Drop-in for OpenAI:
`docker run -e PRIVAITE_API_KEYS=change-me -e OPENAI_API_KEY=sk-... ghcr.io/crp4222/privaite`.

### Changed
- Internal refactoring for a cleaner, less-duplicated codebase. No change in
behavior or detection.
- Clearer Open WebUI / Docker connection docs (which URL, which key).

## [0.2.13] - 2026-07-03

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,11 @@ python -m privaite --reload

### 4. Connect

Point any OpenAI-compatible client to `http://localhost:8400/v1` with your proxy API key. Ready-to-run client snippets (curl, Python, Node) are in [`examples/`](examples/).
Point any OpenAI-compatible client at `http://localhost:8400/v1` and authenticate with your `PRIVAITE_API_KEYS` value (not your provider key). Ready-to-run client snippets (curl, Python, Node) are in [`examples/`](examples/).

**OpenWebUI (Docker):** Admin → Settings → Connections → OpenAI API:
- URL: `http://host.docker.internal:8400/v1`
- Key: your `PRIVAITE_API_KEYS` value
**Open WebUI → Admin → Settings → Connections → OpenAI API:**
- URL: `http://localhost:8400/v1`, or `http://host.docker.internal:8400/v1` if Open WebUI itself runs in Docker
- Key: your `PRIVAITE_API_KEYS` value. Your real OpenAI key never goes here; it stays in the PrivAiTe container (see [Docker](#docker)).

If you would rather not run a separate proxy, there is also an in-process Open
WebUI filter (see [Open WebUI filter](#open-webui-filter) below).
Expand All @@ -247,6 +247,11 @@ docker run -d -p 8400:8400 \
ghcr.io/crp4222/privaite
```

Two keys, two roles, like any proxy: `PRIVAITE_API_KEYS` is what your client (Open
WebUI, your app) sends to PrivAiTe; `OPENAI_API_KEY` is your real OpenAI key that
PrivAiTe uses upstream. The provider key stays in the container and never reaches
your client, which is the whole point.

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:

Expand Down
2 changes: 1 addition & 1 deletion privaite/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.13"
__version__ = "0.3.0"
47 changes: 18 additions & 29 deletions privaite/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
record_pii_stats,
)
from privaite.api.pipeline import (
anonymize_or_error,
call_provider,
dump_response,
sse_response,
resolve_input,
stream_or_error,
validate_model,
)
from privaite.config.schema import PrivAiTeConfig
Expand Down Expand Up @@ -66,43 +66,32 @@ async def chat_completions(
if error is not None:
return error

mapping = None
async def _anonymize() -> tuple[list, Any]:
anon, mapping = await pii_engine.process_request(messages)
record_pii_stats(request, mapping)
return anon, mapping

if config.pii.enabled and pii_engine is not None:

async def _anonymize() -> tuple[list, Any]:
anon, mapping = await pii_engine.process_request(messages)
record_pii_stats(request, mapping)
return anon, mapping

result, error = await anonymize_or_error(_anonymize, config, logger)
if error is not None:
return error
if result is not None:
messages, mapping = result
messages, mapping, error = await resolve_input(
config.pii.enabled and pii_engine is not None, messages, _anonymize, config, logger
)
if error is not None:
return error

kwargs = {k: v for k, v in body.items() if k not in ("model", "messages", "stream")}

if stream:
litellm_stream, error = await call_provider(
from privaite.streaming.handler import StreamingHandler

deanon_config = config.pii.deanonymization if config.pii.enabled else None
return await stream_or_error(
lambda: provider_router.streaming_completion(
model_alias=model, messages=messages, **kwargs
),
lambda s: StreamingHandler.stream_response(
litellm_stream=s, mapping=mapping, deanonymizer_config=deanon_config
),
model, logger,
)
if error is not None:
return error

from privaite.streaming.handler import StreamingHandler

deanon_config = config.pii.deanonymization if config.pii.enabled else None
return sse_response(
StreamingHandler.stream_response(
litellm_stream=litellm_stream,
mapping=mapping,
deanonymizer_config=deanon_config,
)
)

response, error = await call_provider(
lambda: provider_router.completion(model_alias=model, messages=messages, **kwargs),
Expand Down
63 changes: 26 additions & 37 deletions privaite/api/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
record_pii_stats,
)
from privaite.api.pipeline import (
anonymize_or_error,
call_provider,
dump_response,
sse_response,
resolve_input,
stream_or_error,
validate_model,
)
from privaite.config.schema import PrivAiTeConfig
Expand All @@ -42,50 +42,39 @@ async def completions(
if error is not None:
return error

mapping = None

if config.pii.enabled and pii_engine is not None:

async def _anonymize() -> tuple[Any, Any]:
if isinstance(prompt, list) and all(isinstance(p, str) for p in prompt):
msgs = [{"role": "user", "content": p} for p in prompt]
msgs, mapping = await pii_engine.process_request(msgs)
anon_prompt: Any = [m["content"] for m in msgs]
else:
msgs = [{"role": "user", "content": prompt}]
msgs, mapping = await pii_engine.process_request(msgs)
anon_prompt = msgs[0]["content"]
record_pii_stats(request, mapping)
return anon_prompt, mapping

result, error = await anonymize_or_error(_anonymize, config, logger)
if error is not None:
return error
if result is not None:
prompt, mapping = result
async def _anonymize() -> tuple[Any, Any]:
if isinstance(prompt, list) and all(isinstance(p, str) for p in prompt):
msgs = [{"role": "user", "content": p} for p in prompt]
msgs, mapping = await pii_engine.process_request(msgs)
anon_prompt: Any = [m["content"] for m in msgs]
else:
msgs = [{"role": "user", "content": prompt}]
msgs, mapping = await pii_engine.process_request(msgs)
anon_prompt = msgs[0]["content"]
record_pii_stats(request, mapping)
return anon_prompt, mapping

prompt, mapping, error = await resolve_input(
config.pii.enabled and pii_engine is not None, prompt, _anonymize, config, logger
)
if error is not None:
return error

kwargs = {k: v for k, v in body.items() if k not in ("model", "prompt", "stream")}

if stream:
litellm_stream, error = await call_provider(
from privaite.streaming.handler import StreamingHandler

deanon_config = config.pii.deanonymization if config.pii.enabled else None
return await stream_or_error(
lambda: provider_router.streaming_text_completion(
model_alias=model, prompt=prompt, **kwargs
),
lambda s: StreamingHandler.stream_text_response(
litellm_stream=s, mapping=mapping, deanonymizer_config=deanon_config
),
model, logger,
)
if error is not None:
return error

from privaite.streaming.handler import StreamingHandler

deanon_config = config.pii.deanonymization if config.pii.enabled else None
return sse_response(
StreamingHandler.stream_text_response(
litellm_stream=litellm_stream,
mapping=mapping,
deanonymizer_config=deanon_config,
)
)

response, error = await call_provider(
lambda: provider_router.text_completion(model_alias=model, prompt=prompt, **kwargs),
Expand Down
37 changes: 37 additions & 0 deletions privaite/api/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,49 @@ async def call_provider(
return None, provider_error_response(exc)


async def resolve_input(
pii_enabled: bool,
raw: T,
anonymize: Callable[[], Awaitable[tuple[T, Any]]],
config: PrivAiTeConfig,
log: logging.Logger,
) -> tuple[T, Any, JSONResponse | None]:
"""Anonymize an endpoint's input under the fail-closed policy, returning
(input_to_forward, mapping, error). The input is the ``raw`` payload unchanged
when PII is off, when ``on_error: allow`` opts out, or on error (unused then);
otherwise it is the anonymized payload with its reversible mapping.
"""
if not pii_enabled:
return raw, None, None
result, error = await anonymize_or_error(anonymize, config, log)
if error is not None:
return raw, None, error
if result is None:
return raw, None, None
payload, mapping = result
return payload, mapping, None


def sse_response(generator: AsyncIterator[str]) -> StreamingResponse:
return StreamingResponse(
generator, media_type="text/event-stream", headers=dict(SSE_HEADERS)
)


async def stream_or_error(
provider_call: Callable[[], Awaitable[Any]],
make_stream: Callable[[Any], AsyncIterator[str]],
model: str,
log: logging.Logger,
) -> StreamingResponse | JSONResponse:
"""Open a provider stream (mapping any failure to the OpenAI error shape) and
wrap the restored generator as an SSE response."""
litellm_stream, error = await call_provider(provider_call, model, log)
if error is not None:
return error
return sse_response(make_stream(litellm_stream))


def dump_response(response: Any) -> dict:
if hasattr(response, "model_dump"):
return response.model_dump()
Expand Down
61 changes: 27 additions & 34 deletions privaite/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,54 +37,47 @@ class PresidioDetectorConfig(BaseModel):
entities: list[str] | None = None


class MLModelDetectorConfig(BaseModel):
# The openai/privacy-filter model's label set, shared by its ONNX and torch backends.
_PRIVACY_FILTER_LABELS = {
"private_person": "PERSON",
"private_email": "EMAIL_ADDRESS",
"private_phone": "PHONE_NUMBER",
"private_address": "LOCATION",
"private_date": "DATE_TIME",
"private_url": "URL",
"account_number": "FINANCIAL",
"secret": "SECRET",
}


class _PrivacyFilterDetectorConfig(BaseModel):
"""Shared config for the two openai/privacy-filter backends; they differ only in
runtime knobs (ONNX variant vs torch dtype/cache)."""

enabled: bool = False
model_name: str = "openai/privacy-filter"
# Pin a Hugging Face revision (tag or commit sha) so a rewritten model repo
# cannot silently swap the weights this proxy runs. None = latest.
revision: str | None = None
# Off by default: a PII proxy must not execute code shipped inside a model
# repo unless the operator explicitly opts in for a custom-code model.
# Off by default: a PII proxy must not execute code shipped inside a model repo
# unless the operator explicitly opts in for a custom-code model.
trust_remote_code: bool = False
device: str = "auto"
torch_dtype: str = "float16"
score_threshold: float = 0.5
label_mapping: dict[str, str] = Field(
default_factory=lambda: dict(_PRIVACY_FILTER_LABELS)
)


class MLModelDetectorConfig(_PrivacyFilterDetectorConfig):
torch_dtype: str = "float16"
batch_size: int = 1
label_mapping: dict[str, str] = Field(default_factory=lambda: {
"private_person": "PERSON",
"private_email": "EMAIL_ADDRESS",
"private_phone": "PHONE_NUMBER",
"private_address": "LOCATION",
"private_date": "DATE_TIME",
"private_url": "URL",
"account_number": "FINANCIAL",
"secret": "SECRET",
})


class OnnxDetectorConfig(BaseModel):
enabled: bool = False
model_name: str = "openai/privacy-filter"
# Pin a Hugging Face revision (tag or commit sha); None = latest.
revision: str | None = None
# Off by default; only the tokenizer loads from the repo and the default
# model needs no repo code.
trust_remote_code: bool = False
class OnnxDetectorConfig(_PrivacyFilterDetectorConfig):
onnx_variant: str = "q4f16"
device: str = "auto"
score_threshold: float = 0.5
max_length: int = 128000
cache_dir: str | None = None
label_mapping: dict[str, str] = Field(default_factory=lambda: {
"private_person": "PERSON",
"private_email": "EMAIL_ADDRESS",
"private_phone": "PHONE_NUMBER",
"private_date": "DATE_TIME",
"private_address": "LOCATION",
"private_url": "URL",
"account_number": "FINANCIAL",
"secret": "SECRET",
})


class BertNERDetectorConfig(BaseModel):
Expand Down
Loading