In February 2026, OpenAI updated its privacy policy to enable ad personalization based on chat content for free-tier users, and for the first time acknowledged receiving data from advertising partners. In January, it emerged that the acting director of CISA had uploaded sensitive government documents to public ChatGPT months earlier, despite having a secure internal alternative. Enterprise bans at Samsung, JPMorgan, and Goldman Sachs, first enacted in 2023, remain in place, and the list of companies restricting public AI tools continues to expand.
The pattern is clear: companies want GPT-class AI. Legal says no.
This tutorial shows you how to ship AI features on sensitive data, with any model, any provider, without ever sending real PII to an external API.
The problem in one diagram
The LLM gets a coherent document. It reasons correctly. Your users get real names back. OpenAI never sees John Smith or john@acme.com.
This is called reversible sanitization. It’s architecturally different from redaction, which destroys the data. Here we replace PII with consistent tokens before the API call and restore originals in the response.
Why this matters for compliance
Before diving into code, here’s why your compliance team will care.
GDPR Article 5 (Data Minimization): You’re not transmitting personal data to OpenAI, only pseudonymized tokens. The mapping stays on your side.
GDPR Article 4 (Pseudonymization): The sanitized text cannot be attributed to a specific person without the session mapping, which is stored separately from the transmitted data.
HIPAA Technical Safeguards: PHI never reaches an external API. The pipeline provides demonstrable technical controls that appear in your audit trail.
Enterprise AI Acceptable Use Policies: Most corporate AI bans target the risk of sensitive data leaving the organization. This architecture eliminates that risk while preserving the AI workflow.
This isn’t legal advice, run it by your compliance team. But it gives them something concrete to evaluate rather than asking them to trust that OpenAI’s terms are sufficient.
What you’ll build
A Python service that:
- Accepts any prompt containing real PII
- Sanitizes it before sending to OpenAI or Anthropic
- Rehydrates the response before returning it to the user
- Works with a single base URL change, no prompt changes, no model changes
We’ll use LiteLLM as the LLM client, which means the same code works across OpenAI, Anthropic, Mistral, Gemini, and any other provider. We’ll use AmbientMeta as the privacy layer.
Prerequisites
pip install litellm ambientmeta python-dotenv
# .env
AMBIENTMETA_API_KEY=am_live_your_key_here
OPENAI_API_KEY=sk-your-openai-key
ANTHROPIC_API_KEY=sk-ant-your-anthropic-key
Get your AmbientMeta API key at ambientmeta.com. Free tier includes 1,000 requests/month, no card required.
Part 1: The two-call pattern
The core pattern is three steps: sanitize, call the LLM, rehydrate.
import os
from ambientmeta import AmbientMetaClient
from litellm import completion
client = AmbientMetaClient(api_key=os.getenv("AMBIENTMETA_API_KEY"))
def private_completion(model: str, prompt: str, **kwargs) -> str:
# 1. Sanitize — PII replaced with tokens, session_id tracks the mapping
sanitized = client.sanitize(prompt)
# 2. LLM call — model never sees real PII
response = completion(
model=model,
messages=[{"role": "user", "content": sanitized.text}],
**kwargs
)
# 3. Rehydrate — tokens replaced with original values in the response
result = client.rehydrate(
text=response.choices[0].message.content,
session_id=sanitized.session_id
)
return result.text
That’s it. Let’s prove it works.
Part 2: Real-world example, customer support
Here’s a realistic prompt a customer support bot might process:
prompt = """
Customer: Sarah Chen (sarah.chen@techcorp.io)
Account: #ACC-7729-X
Issue: My credit card ending in 4821 was charged $1,247.00 on March 15th
but I never received the software license for order ORD-2024-11847.
My phone number on file is (415) 892-3301.
Please draft a response to this customer resolving their billing dispute.
"""
Without privacy protection, every detail, name, email, account number, card info, phone, goes straight to OpenAI.
With the two-call pattern:
response = private_completion(model="gpt-4o", prompt=prompt)
print(response)
What OpenAI actually receives:
Customer: [PERSON_1] ([EMAIL_1])
Account: #[REFERENCE_ID_1]
Issue: My credit card ending in [CREDIT_CARD_1] was charged $1,247.00 on March 15th
but I never received the software license for order [REFERENCE_ID_2].
My phone number on file is [PHONE_1].
Please draft a response to this customer resolving their billing dispute.
What your user receives:
Dear Sarah Chen,
Thank you for reaching out about your order ORD-2024-11847. I can see that
your credit card ending in 4821 was charged $1,247.00 on March 15th...
The LLM reasoned correctly about a billing dispute. Sarah Chen’s data never left your infrastructure.
Part 3: Switching providers, zero code changes
This is where LiteLLM shines. The same private_completion function works with any provider:
# OpenAI
response_openai = private_completion(
model="gpt-4o",
prompt=prompt
)
# Anthropic — one line change
response_anthropic = private_completion(
model="anthropic/claude-sonnet-4-20250514",
prompt=prompt
)
LiteLLM normalizes the response format across providers. AmbientMeta’s session_id is tied to the sanitized text, not the model, so rehydration works identically regardless of which provider generated the response.
Part 4: Multi-turn conversations
Real applications need conversation history. AmbientMeta handles this with persistent session IDs, pass the same session_id across turns and the same person always maps to the same placeholder.
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class PrivateConversation:
model: str
session_id: Optional[str] = None
messages: List[dict] = field(default_factory=list)
client: AmbientMetaClient = field(
default_factory=lambda: AmbientMetaClient(
api_key=os.getenv("AMBIENTMETA_API_KEY")
)
)
def chat(self, user_message: str) -> str:
# Sanitize with the same session_id for consistent placeholders
sanitized = self.client.sanitize(
user_message,
session_id=self.session_id
)
# Capture session_id on first turn, reuse on subsequent turns
if self.session_id is None:
self.session_id = sanitized.session_id
# Add sanitized message to history
self.messages.append({
"role": "user",
"content": sanitized.text
})
# LLM call with full sanitized history
response = completion(
model=self.model,
messages=self.messages
)
assistant_message = response.choices[0].message.content
# Store sanitized version in history (never real PII)
self.messages.append({
"role": "assistant",
"content": assistant_message
})
# Rehydrate and return to user
rehydrated = self.client.rehydrate(
text=assistant_message,
session_id=self.session_id
)
return rehydrated.text
Usage:
conv = PrivateConversation(model="anthropic/claude-sonnet-4-20250514")
r1 = conv.chat("I'm James Murphy, james@startup.io. I need help drafting an NDA.")
r2 = conv.chat("The counterparty is Acme Corp. Their contact is lisa@acme.com.")
r3 = conv.chat("Add a clause about the $500,000 investment amount.")
# All three responses reference James Murphy, james@startup.io, etc.
# Same person = same [PERSON_1] across every turn.
# None of it ever reached Anthropic.
Part 5: The drop-in proxy
If you don’t want to change your application code at all, use the AmbientMeta proxy endpoint. It accepts the same request format as OpenAI’s API. You just add two headers:
X-API-Key— your AmbientMeta API keyX-Provider-Key— your upstream LLM provider key (OpenAI, Anthropic, etc.)
The proxy sanitizes inbound messages, forwards the sanitized request to the LLM, and rehydrates the response before returning it to you. Under the hood, it uses a streaming rehydration buffer so placeholder replacement happens token-by-token with minimal latency.
import httpx
response = httpx.post(
"https://api.ambientmeta.com/v1/proxy/chat/completions",
headers={
"X-API-Key": os.getenv("AMBIENTMETA_API_KEY"),
"X-Provider-Key": os.getenv("OPENAI_API_KEY"),
"Content-Type": "application/json",
},
json={
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Help John Smith at john@example.com..."}],
"stream": False
}
)
# Response already rehydrated — John Smith is back
print(response.json()["choices"][0]["message"]["content"])
Or with the OpenAI Python client:
from openai import OpenAI
client = OpenAI(
api_key="unused", # proxy authenticates via headers, not this field
base_url="https://api.ambientmeta.com/v1/proxy",
default_headers={
"X-API-Key": os.getenv("AMBIENTMETA_API_KEY"),
"X-Provider-Key": os.getenv("OPENAI_API_KEY")
}
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Help John Smith at john@example.com..."}]
)
print(response.choices[0].message.content)
For multi-turn proxy conversations, pass the X-Session-Id header from the response to maintain consistent placeholders across turns.
Part 6: Entity detection
AmbientMeta’s detection pipeline catches the following entity types:
| Entity Type | Example |
|---|---|
PERSON | John Smith, Sarah Chen |
EMAIL_ADDRESS | john@acme.com |
PHONE_NUMBER | (415) 892-3301 |
CREDIT_CARD | 4532 0151 1283 0366 |
SSN | 123-45-6789 |
ADDRESS | 123 Main St, San Francisco, CA |
LOCATION | San Francisco, Georgia, Berlin |
ORGANIZATION | Acme Corp, TechCorp |
DATE_OF_BIRTH | 01/15/1985 |
IP_ADDRESS | 192.168.1.100 |
URL | https://internal.corp.com/admin |
REFERENCE_ID | ORD-2024-11847, ACC-7729-X |
For domain-specific entities, add custom patterns:
client.add_pattern(
name="employee_id",
pattern=r"EMP-\d{6}"
)
# Now EMP-123456 gets detected and replaced as [EMPLOYEE_ID_1] automatically
Part 7: Error handling, fail closed
from ambientmeta.exceptions import (
AmbientMetaRateLimitError,
AmbientMetaSessionExpiredError,
AmbientMetaAuthError
)
def safe_private_completion(model: str, prompt: str) -> str:
try:
sanitized = client.sanitize(prompt)
response = completion(
model=model,
messages=[{"role": "user", "content": sanitized.text}]
)
return client.rehydrate(
text=response.choices[0].message.content,
session_id=sanitized.session_id
).text
except AmbientMetaRateLimitError:
# Free tier: 20 req/min, 1,000/month. Back off and retry.
raise
except AmbientMetaSessionExpiredError:
# Sessions expire after 24 hours. Re-sanitize to create a new session.
raise
except AmbientMetaAuthError:
raise
except Exception as e:
# CRITICAL: do NOT fall back to sending raw text.
# Fail closed — better to surface an error than leak PII.
raise RuntimeError(f"Privacy layer error: {e}") from e
The key principle: always fail closed. If sanitization fails for any reason, raise an error rather than falling back to sending the raw prompt. A silent fallback defeats the entire guarantee.
Performance
AmbientMeta adds minimal overhead to your LLM call:
| Operation | p50 | p99 |
|---|---|---|
| Sanitize | 18ms | 45ms |
| Rehydrate | 4ms | 12ms |
| GPT-4o call | ~800ms | ~2,000ms |
| Total overhead | ~3% | ~3% |
The sanitize and rehydrate calls are the only added latency. For most applications, the addition is imperceptible.
Limitations and trade-offs
No sanitizer is perfect. A few things worth knowing:
False negatives exist. PII embedded in unusual formats, concatenated inside URLs, encoded in JSON keys, or split across sentences, may slip through. Custom patterns help close domain-specific gaps, and the detection pipeline improves over time from user corrections.
Context loss. When “the CEO emailed the intern” becomes [PERSON_1] emailed [PERSON_2], the LLM loses hierarchical context. For most use cases, summarization, drafting, extraction, this doesn’t affect output quality. For tasks that depend on role or status context, consider restructuring the prompt to preserve that information outside of the PII itself.
Session TTL. Sessions expire after 24 hours. For long-running workflows, re-sanitize to create a new session.
The whole pattern in 10 lines
from ambientmeta import AmbientMetaClient
from litellm import completion
client = AmbientMetaClient(api_key="am_live_...")
def ask(model, prompt):
s = client.sanitize(prompt)
r = completion(model=model, messages=[{"role": "user", "content": s.text}])
return client.rehydrate(r.choices[0].message.content, s.session_id).text
# OpenAI
print(ask("gpt-4o", "Help John Smith at john@acme.com with invoice #INV-1234"))
# Anthropic — same function, different model string
print(ask("anthropic/claude-sonnet-4-20250514", "Help John Smith at john@acme.com with invoice #INV-1234"))
PII never leaves your infrastructure. The model never knows the difference. Your users get real names back in the response.
Next steps
Try the two-call pattern on your existing codebase. It’s three lines on top of what you already have. Then explore:
- Get your free API key — 1,000 requests/month, no card required
- API docs — full endpoint reference
- LangChain integration —
pip install langchain-ambientmeta - LlamaIndex integration —
pip install llamaindex-ambientmeta
In Part 2 of this series, we cover document-level workflows: upload a PDF or Word doc, sanitize it, run LLM analysis over the sanitized content, and get back results with real names and amounts restored, all with a single API call.