Skip to main content

PIIGhost

CI codecov PyPI version Python versions License: MIT Security: bandit Discord

piighost is a Python library that keeps PII (personally identifiable information) from ever reaching a language model, without getting in the way of what your app needs to do.

It spots PII with detectors (regex, NER, or another LLM) and swaps each value for a stable placeholder, so john.doe@example.com becomes <<EMAIL:1>> and the model only ever works on de-identified text. When the LLM answers with those placeholders, piighost puts the real values back, so the end user reads john.doe@example.com and never notices a thing. Tool-using agents get the same treatment. A tool that genuinely needs the real address receives it in clear, while the LLM that decided to call it still sees only <<EMAIL:1>>.

The mapping between a value and its placeholder also sticks around for the whole conversation. If john.doe@example.com comes up again three messages later, it stays <<EMAIL:1>>, so the model can still follow the thread.

A user chats with an agent: PII values are replaced by placeholders before reaching the model and restored afterwards for the user and for tool calls.

The LLM only sees placeholders. The tool receives the real address, the user gets a clear-text reply, and your agent code stays the same.

[!NOTE] piighost performs reversible de-identification. Because the mapping between a value and its placeholder is kept so the data can be restored, this is pseudonymisation under the GDPR, not permanent anonymisation. The real values stay stored for the duration of the conversation and must be protected accordingly.

Why PIIGhost

Most PII tooling stops at detection. Presidio, GLiNER, spaCy, and regex catalogs all find entities in text, and they do it well. The hard part for an LLM agent is everything after detection: swapping values without wrecking the model's reasoning, keeping one value mapped to one token across a conversation, handing tools the real value while the model sees only the token, and putting the originals back in the reply. That orchestration is what PIIGhost is.

What PIIGhost adds on top:

  • Pluggable detectors: regex catalogs (generic, US, EU, FR), NER (GLiNER2, spaCy, Transformers), an LLM detector, plus exact-match, composite, and chunked detectors (chunking splits text that overruns a model's context window), and you keep the one you trust (Presidio plugs in through an extra).
  • Reversible, transparent tokens: each value becomes a stable id like <<PERSON:1>> and is put back automatically, so the end user reads john.doe@example.com and never sees a token; label-only, masked, and keyed-hash factories are available too.
  • Consistent across a conversation: the same value keeps the same token for the whole thread, backed by in-process, Redis, or SQLAlchemy memory (Redis and SQL can encrypt values at rest and hash keys).
  • Agent integrations with a tool boundary: LangChain middleware, Pydantic AI hooks, and LlamaIndex; the tool receives the real value while the model sees only the token, with token-by-token streaming restoration.
  • A customizable staged pipeline: detect, link, resolve overlaps, expand, anonymize, and an optional guard rail that refuses a reply with residual PII (a detector, an LLM, or Mistral moderation); swap in fuzzy matching to tolerate typos or add your own stage.
  • Config-driven and self-hostable: build a whole pipeline from a TOML/JSON file with a CLI to validate it, run it in your process, or as a service through the companion piighost-api (OpenAI- and Anthropic-compatible proxies).
  • Typed and observable: ships py.typed and a minimal core with everything heavy behind extras, plus OpenTelemetry per-stage spans (viewable in Langfuse or Jaeger) with optional payload redaction.
  • Scope, live text and conversations: PIIGhost protects a running conversation message by message, not a static dataset.

For how it stacks up against Presidio, LangChain, the cloud APIs, and others, see How PIIGhost compares.

Limitations and trade-offs

  • The token does not embed the encrypted value, on purpose. Unlike a format-preserving encryption token (where the ciphertext is the token, e.g. Google DLP), PIIGhost uses an id (<<PERSON:1>>) backed by a cache. The reason: a token that carries the ciphertext can be captured today and cracked in 20 years ("harvest now, decrypt later", the quantum threat to classical crypto), whereas an id reveals nothing on its own. In return, you need a cache to hold the token-to-value mapping, so a memory backend to deploy, share across workers, and persist in production.
  • That cache stores the real values, so reversibility is pseudonymisation, not anonymisation (GDPR). The real values stay stored for the duration of the conversation. The library gives you the means to protect them (AES-GCM encryption of the values, Argon2id hashing of the keys), but the database architecture itself must be secured in production once you use Redis or PostgreSQL.
  • No dataset anonymization. No k-anonymity, l-diversity, differential privacy, or tabular data. PIIGhost protects live text and conversations, not a whole dataset; for that, see ARX, Amnesia, or Google DLP.
  • No checksum validation (Luhn / IBAN / NIR), by choice. The RegexDetector matches on shape alone so it never lets a real value mangled by OCR leak (a checksum would reject it and it would pass in clear). In exchange, it sometimes flags a string that only looks like PII, which costs nothing beyond one extra token.

Quickstart

pip install piighost   # or: uv add piighost

De-identify a text

ExactMatchDetector de-identifies a dictionary of known values without downloading a model.

import asyncio

from piighost.components.detector import ExactMatchDetector
from piighost.pipeline import AnonymizationPipeline

detector = ExactMatchDetector({"John Doe": "PERSON", "john.doe@example.com": "EMAIL"})
pipeline = AnonymizationPipeline(detector)

result = asyncio.run(pipeline.anonymize("Write to John Doe at john.doe@example.com."))
print(result.text)  # Write to <<PERSON:1>> at <<EMAIL:1>>.

Conversations and agents (LangChain)

The middleware wraps a conversational pipeline and handles every agent turn for you, so the same de-identification applies without any change to your agent logic.

pip install 'piighost[langchain]'   # or: uv add 'piighost[langchain]'
import asyncio

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool

from piighost.components.detector import ExactMatchDetector
from piighost.integrations.langchain import PIIAnonymizationMiddleware
from piighost.pipeline import ThreadAnonymizationPipeline

SYSTEM_PROMPT = (
    "Some inputs contain placeholders like <<PERSON:1>> that stand in for real "
    "values withheld for privacy. Treat each placeholder as the real value, never "
    "comment on its format, and pass it to tools unchanged."
)


@tool
def send_mail(to: str, body: str) -> str:
    """Send an email to `to` with the given body."""
    print(f"[tool] send_mail received to={to!r}")
    return "Email successfully sent."


async def main() -> None:
    # This example calls OpenAI, so set OPENAI_API_KEY in your environment first.
    labels = {"Patrick Dupont": "PERSON", "patrick@acme.com": "EMAIL"}
    detector = ExactMatchDetector(labels)
    pipeline = ThreadAnonymizationPipeline(detector)
    middleware = PIIAnonymizationMiddleware(pipeline)
    # gpt-5.6-terra is a reasoning model; reasoning_effort="none" lets it call
    # function tools over chat/completions.
    model = init_chat_model("openai:gpt-5.6-terra", reasoning_effort="none")
    # The system prompt tells the model to treat placeholders as real values and
    # pass them to tools unchanged, so it does not balk at the tokens.
    agent = create_agent(
        model=model,
        system_prompt=SYSTEM_PROMPT,
        tools=[send_mail],
        middleware=[middleware],
    )
    config = {"configurable": {"thread_id": "demo-thread"}}

    message = HumanMessage(
        "Use the send_mail tool to send a welcome note to Patrick Dupont at patrick@acme.com."
    )
    result = await agent.ainvoke({"messages": [message]}, config=config)
    print(f"user sees: {result['messages'][-1].content!r}")


if __name__ == "__main__":
    asyncio.run(main())

This is the LangChain integration, but it is only one option. piighost also has connectors for Pydantic AI and LlamaIndex, and the companion piighost-api exposes OpenAI- and Anthropic-compatible proxies, so you can move de-identification to the HTTP boundary with only a base URL change.

For a real detector and the conversational pipeline, see the Quickstart and the LangChain integration.

Documentation

Full documentation

Browse the docs by section

Project

Release files for piighost 1.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for piighost 1.5.0
File Size Uploaded
piighost-1.5.0.tar.gz 99.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for piighost 1.5.0
File Interpreter ABI Platform
piighost-1.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 249.5 kB

Release files / piighost-1.5.0.tar.gz

Download URL piighost-1.5.0.tar.gz
Size 99.9 kB
Tags Source
SHA-256 checksum
How to use checksums
e5ae7a1723c75d161717000c8712fd58160038c7c970854bf7e3be2e523e44ae
BLAKE2b-256 checksum
How to use checksums
7e4f738f1546e31458c31292380d0d5bed77078b8d58b0613efa989b4d72cb40
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / piighost-1.5.0-py3-none-any.whl

Download URL piighost-1.5.0-py3-none-any.whl
Size 149.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ae3c1fff4a6ce724418d9c2324f37da588c97592a3db1cf31bde4359a86d0da8
BLAKE2b-256 checksum
How to use checksums
3401ae400a8341ba5eb818fda19734628b0331a0e968a4c2f553f28a64d64536
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

1.8.0

2 release files

1.7.1

2 release files

1.7.0

2 release files

1.6.1

2 release files

1.6.0

2 release files

This release

1.5.0 This release

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.14.0

2 release files

0.12.0

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page