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

Privacy layer architecture — before and after

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.

Multi-turn session consistency

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 key
  • X-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 TypeExample
PERSONJohn Smith, Sarah Chen
EMAIL_ADDRESSjohn@acme.com
PHONE_NUMBER(415) 892-3301
CREDIT_CARD4532 0151 1283 0366
SSN123-45-6789
ADDRESS123 Main St, San Francisco, CA
LOCATIONSan Francisco, Georgia, Berlin
ORGANIZATIONAcme Corp, TechCorp
DATE_OF_BIRTH01/15/1985
IP_ADDRESS192.168.1.100
URLhttps://internal.corp.com/admin
REFERENCE_IDORD-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:

Operationp50p99
Sanitize18ms45ms
Rehydrate4ms12ms
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.

Try AmbientMeta

Next steps

Try the two-call pattern on your existing codebase. It’s three lines on top of what you already have. Then explore:

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.