Skip to content

LLM Prompt Redaction ​

When sending prompts to LLM providers like OpenAI or Anthropic, sensitive user data in the prompt is transmitted to a third-party API. sensored provides wrapper adapters that automatically redact PII from prompts before they leave your process and restore placeholders in the responses.

How it works ​

  1. Redact: PII in the prompt is replaced with numbered placeholders (e.g. john@example.com → [EMAIL_1])
  2. Send: The redacted prompt is sent to the LLM API
  3. Restore: Placeholders in the response are restored to original values before returning to your code

The restoration map is held in memory for the duration of the API call. The same PII value gets the same placeholder within one call, so the LLM sees consistent references.

OpenAI ​

Wraps an OpenAI client, redacting the messages array and restoring responses.

Install ​

bash
bun add openai

Usage ​

ts
import OpenAI from "openai";
import { wrapOpenAI } from "sensored/adapters/openai";

const client = wrapOpenAI(new OpenAI(), {
  presets: ["pii"],
  rules: {
    person_name_lite: { action: "redact" },
    email: { action: "redact" },
    phone: { action: "redact" },
  },
});

const response = await client.chat.completions.create({
  model: "gpt-4o",
  messages: [
    {
      role: "user",
      content: "Send an email to john@example.com about their order.",
    },
  ],
});
// Model receives: "Send an email to [EMAIL_1] about their order."
// Response is restored: placeholders become original values automatically.
console.log(response.choices[0]?.message?.content);

Streaming ​

Streaming responses are handled automatically. Placeholders split across chunk boundaries are reassembled correctly using StreamRestorer:

ts
const stream = await client.chat.completions.create({
  model: "gpt-4o",
  messages: [
    { role: "user", content: "Email john@example.com about order #123" },
  ],
  stream: true,
});

for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content;
  if (delta) process.stdout.write(delta);
}

Tool calls ​

Tool-call arguments are redacted and restored automatically:

ts
const response = await client.chat.completions.create({
  model: "gpt-4o",
  messages: [
    {
      role: "user",
      content: "Call the send_email function for alice@example.com",
    },
  ],
  tools: [
    {
      type: "function",
      function: {
        name: "send_email",
        parameters: {
          type: "object",
          properties: { to: { type: "string" } },
        },
      },
    },
  ],
});
// Tool call arguments are restored in the response.

Anthropic ​

Wraps an Anthropic client, redacting the messages array and system prompt, and restoring responses.

Install ​

bash
bun add @anthropic-ai/sdk

Usage ​

ts
import Anthropic from "@anthropic-ai/sdk";
import { wrapAnthropic } from "sensored/adapters/anthropic";

const client = wrapAnthropic(new Anthropic(), {
  presets: ["pii"],
  rules: {
    person_name_lite: { action: "redact" },
    email: { action: "redact" },
    phone: { action: "redact" },
  },
});

const response = await client.messages.create({
  model: "claude-sonnet-4-5-20250514",
  max_tokens: 1024,
  system: "You are a helpful assistant for alice@corp.com",
  messages: [{ role: "user", content: "Send the invoice to bob@example.com" }],
});
// Model receives redacted system + messages; response is restored.
for (const block of response.content) {
  if (block.type === "text") console.log(block.text);
}

What gets redacted ​

The Anthropic adapter redacts only the messages and system fields. Other parameters (model, max_tokens, temperature, top_p, stop_sequences, etc.) pass through unmodified.

Streaming ​

Streaming responses are handled automatically, including text_delta and partial_json events:

ts
const stream = await client.messages.create({
  model: "claude-sonnet-4-5-20250514",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Email alice@example.com" }],
  stream: true,
});

for await (const event of stream) {
  if (event.type === "content_block_delta") {
    const delta = event.delta;
    if (delta.type === "text_delta") {
      process.stdout.write(delta.text);
    }
  }
}

System prompt as text blocks ​

The adapter handles both string and array (text block) system prompts:

ts
const response = await client.messages.create({
  model: "claude-sonnet-4-5-20250514",
  max_tokens: 1024,
  system: [{ type: "text", text: "Instructions for bob@example.com" }],
  messages: [{ role: "user", content: "Hello" }],
});

One-off redaction ​

For cases where you don't need a client wrapper, use redactPrompt:

ts
import { redactPrompt } from "sensored/adapters/openai";

const { text, map } = redactPrompt(
  "Contact john@example.com about order #123",
  { presets: ["pii"], rules: {} },
);
// text: "Contact [EMAIL_1] about order #123"
// map: { "[EMAIL_1]": "john@example.com" }

Multi-turn sessions ​

For multi-turn LLM conversations, use createSession to maintain a persistent placeholder map across turns. The same PII value maps to the same placeholder in every redact() call within a session, so the model sees consistent references throughout the conversation.

Basic usage ​

ts
import { createSession } from "sensored/session";

const session = createSession({
  presets: ["pii"],
  rules: { email: { action: "redact" } },
});

const turn1 = session.redact("Email john@example.com");
// "Email [EMAIL_1]"
const turn2 = session.redact("Also CC john@example.com");
// "Also CC [EMAIL_1]"  — same value, same placeholder

With adapter wrappers ​

Pass a session instead of a config object to wrapOpenAI or wrapAnthropic:

ts
import OpenAI from "openai";
import { wrapOpenAI } from "sensored/adapters/openai";
import { createSession } from "sensored/session";

const session = createSession({
  presets: ["pii"],
  rules: { email: { action: "redact" } },
});

const client = wrapOpenAI(new OpenAI(), session);

Hydration ​

Pass an existing RestorationMap to resume a session:

ts
const session = createSession(config, existingMap);

Reset ​

Clear the map to start a new conversation:

ts
session.reset();

Streaming ​

Use stream() for streaming restoration of model responses:

ts
const restorer = session.stream();
for await (const chunk of modelStream) {
  process.stdout.write(restorer.push(chunk.text));
}
process.stdout.write(restorer.flush());

See Restoration for the full Session API reference.

detectOnly mode ​

Use detectOnly: true to send prompts unmodified while still detecting PII. Useful for auditing what would be redacted before enabling it in production:

ts
const client = wrapOpenAI(new OpenAI(), {
  presets: ["pii"],
  rules: { email: { action: "redact" } },
  detectOnly: true,
});
// Prompts are sent as-is; no redaction or restoration occurs.

Idempotent wrapping ​

Wrapping an already-wrapped client is a no-op. The wrapper uses a Symbol to detect double-wrapping and returns the same client:

ts
const wrapped = wrapOpenAI(client, config);
const doubleWrapped = wrapOpenAI(wrapped, config);
// doubleWrapped === wrapped

Security considerations ​

  • The restoration map contains plaintext PII mappings. Treat it as sensitive — do not log it or serialize it to unencrypted storage.
  • The map persists in memory until garbage-collected. JavaScript strings are immutable and cannot be securely zeroed.
  • Redaction happens before the API call; restoration happens after the response. PII never appears in the request payload sent to the LLM provider.

Released under the MIT License.