Pydantic AI integration
You want a Pydantic AI agent where the model only ever sees tokens, never the real names in the conversation, and where a value keeps the same token from one turn to the next. This page wires that agent end to end with a GLiNER2 detector, a ThreadAnonymizationPipeline, and pii_hooks, the capability that de-identifies around the model.
The capability covers the messages, the user prompt and the model's own replies. It covers the tool boundary too, meaning the tool calls and their results. Under the default strategy a tool receives the real values while the model keeps working on tokens.
1. Build the pipeline over a GLiNER2 detector
Gliner2Detector wraps a GLiNER2 model. Pass the model id as a string and it loads on construction. Pass labels to tell it which entity types to query. Only the detector is required, since the thread pipeline defaults its linker, its anonymizer, and an in-memory conversation store. The default anonymizer emits the delimited <<PERSON:1>> that pii_hooks can find again.
from piighost.components.detector.ner import Gliner2Detector
from piighost.pipeline import ThreadAnonymizationPipeline
detector = Gliner2Detector(
model="fastino/gliner2-multi-v1",
labels=["PERSON", "LOCATION"],
threshold=0.5,
)
pipeline = ThreadAnonymizationPipeline(detector)2. Attach the capability to the agent
pii_hooks takes the pipeline and a thread id, then returns a Pydantic AI capability. Register it with capabilities=[...]. The thread id scopes the tokens, so a value keeps one token for the whole conversation. It is a fixed string here. Pass a callable over the run context, for example lambda ctx: ctx.deps.thread_id, to read it per run.
from pydantic_ai import Agent
from piighost.integrations.pydantic_ai import pii_hooks
hooks = pii_hooks(pipeline, thread_id="thread-42")
agent = Agent("openai:gpt-5.6-terra", capabilities=[hooks])3. Run one turn
The capability de-identifies the prompt before the model reads it, and restores the reply for display. So the model works on <<PERSON:1>> while you read Patrick.
import asyncio
async def main() -> None:
result = await agent.run("Where does Patrick live?")
print(result.output)
asyncio.run(main())The reply is restored for display. Its wording depends on the model, for example:
I have no address for Patrick.Who sees what
GLiNER2 flags Patrick as PERSON in the incoming message. From there the capability substitutes in one direction before the model call, and in the other direction after it:
before_model_requestsends every user and assistant text throughpipeline.anonymize. So the model receivesWhere does <<PERSON:1>> live?. This hook rewrites the assistant texts too. So a value restored for display on an earlier turn is de-identified again before the next model call, and never leaks back into the history.after_model_requestsends the reply throughpipeline.deanonymize, so you read the real value.
The thread_id keeps <<PERSON:1>> bound to Patrick across every turn.
Tokens the model invents
After restoration every issued token is back to its value. So a string that still has the shape of a token was invented by the model, whether by hallucination or prompt injection. pii_hooks takes an invented_strategy that decides what happens then. RAISE refuses it, the fail-closed default. KEEP leaves it. DROP removes it.
from piighost.integrations.langchain import InventedPlaceholderStrategy
hooks = pii_hooks(
pipeline,
thread_id="thread-42",
invented_strategy=InventedPlaceholderStrategy.DROP,
)Tool calls
pii_hooks also covers the tool boundary. tool_strategy governs it, with the same enum the LangChain middleware uses. Under FULL, the default, a tool call's arguments are restored before the tool runs. So a tool that needs Patrick gets it, and not <<PERSON:1>>. The tool's string result is de-identified again before the model reads it, so the model keeps seeing tokens. INPUT restores only the arguments, OUTPUT de-identifies again only the result, and PASSTHROUGH leaves both untouched.
from piighost.integrations.langchain import ToolCallStrategy
hooks = pii_hooks(pipeline, thread_id="thread-42", tool_strategy=ToolCallStrategy.FULL)Assistant values
Not every value is the user's confidential data. Sometimes the model itself introduces a value from its world knowledge. Tokenizing that value would hide it from the model on the next turn, and protect nothing of the user. assistant_strategy decides what happens to a value the assistant introduces, again with the same enum the middleware uses. Under PRESERVE, the default, the value stays in clear. So the model keeps its own knowledge of it, and only known user values are tokenized. ANONYMIZE tokenizes it anyway. IGNORE skips the assistant's messages entirely, so the detector does not run on them.
from piighost.integrations.langchain import EntityCreateByAssistantStrategy
hooks = pii_hooks(
pipeline,
thread_id="thread-42",
assistant_strategy=EntityCreateByAssistantStrategy.ANONYMIZE,
)See also
- To compare with the LangChain agent middleware, see the LangChain integration.
- To swap GLiNER2 for spaCy, a regex pack, or your own detector, see Extending piighost.
- The runnable scripts are in
examples/pydantic_ai/base.py(messages) andexamples/pydantic_ai/tools.py(a tool).