# PasteGuard
-OpenAI-compatible proxy with two privacy modes: route to local LLM or mask PII for configured provider.
+Privacy proxy for LLMs. Masks personal data and secrets before sending prompts to your provider (OpenAI, Anthropic, etc.).
## Tech Stack
│ └── timeouts.ts # HTTP timeout values
├── routes/
│ ├── openai.ts # /openai/v1/* (chat completions + wildcard proxy)
+│ ├── anthropic.ts # /anthropic/v1/* (messages + wildcard proxy)
│ ├── dashboard.tsx # Dashboard routes + API
│ ├── health.ts # GET /health
│ ├── info.ts # GET /info
├── providers/
│ ├── errors.ts # Shared provider errors
│ ├── local.ts # Local LLM client (Ollama/OpenAI-compatible)
-│ └── openai/
-│ ├── client.ts # OpenAI API client
+│ ├── openai/
+│ │ ├── client.ts # OpenAI API client
+│ │ ├── stream-transformer.ts # SSE unmasking for streaming
+│ │ └── types.ts # OpenAI request/response types
+│ └── anthropic/
+│ ├── client.ts # Anthropic API client
│ ├── stream-transformer.ts # SSE unmasking for streaming
-│ └── types.ts # OpenAI request/response types
+│ └── types.ts # Anthropic request/response types
├── masking/
│ ├── service.ts # Masking orchestration
│ ├── context.ts # Masking context management
│ ├── conflict-resolver.ts # Overlapping entity resolution
│ ├── types.ts # Shared masking types
│ └── extractors/
-│ └── openai.ts # OpenAI text extraction/insertion
+│ ├── openai.ts # OpenAI text extraction/insertion
+│ └── anthropic.ts # Anthropic text extraction/insertion
├── pii/
│ ├── detect.ts # Presidio client
│ └── mask.ts # PII masking logic
- `GET /health` - Health check
- `GET /info` - Mode info
-- `POST /openai/v1/chat/completions` - Main endpoint
+- `POST /openai/v1/chat/completions` - OpenAI endpoint
+- `POST /anthropic/v1/messages` - Anthropic endpoint
Response header `X-PasteGuard-PII-Masked: true` indicates PII was masked.
</p>
<p align="center">
- Privacy proxy for LLMs. Masks personal data and secrets before sending to your provider.
+ Privacy proxy for LLMs. Masks personal data and secrets before sending prompts to your provider.
</p>
<p align="center">
## What is PasteGuard?
-When you use LLM APIs, every prompt is sent to external servers — including customer names, emails, and sensitive business data. Many organizations have policies against sending PII to third-party AI services.
+PasteGuard is a privacy proxy that masks personal data and secrets before sending prompts to LLM providers.
-PasteGuard is an OpenAI-compatible proxy that sits between your app and the LLM API. It detects personal data and secrets before they leave your network.
+```
+You send: "Email Dr. Sarah Chen at sarah@hospital.org"
+LLM sees: "Email [[PERSON_1]] at [[EMAIL_ADDRESS_1]]"
+You get: Response with original names restored
+```
**Two ways to protect your data:**
- **Mask Mode** — Replace PII with placeholders, send to your provider, restore in response. No local infrastructure needed.
- **Route Mode** — Send PII requests to a local LLM (Ollama, vLLM, llama.cpp), everything else to your provider. Data never leaves your network.
-Works with OpenAI, Azure, and any OpenAI-compatible API. Just change one URL.
+Just change one URL to start protecting your data.
## Browser Extension (Beta)
- **PII Detection** — Names, emails, phone numbers, credit cards, IBANs, and more
- **Secrets Detection** — API keys, tokens, private keys caught before they reach the LLM
- **Streaming Support** — Real-time unmasking as tokens arrive
-- **24 Languages** — Works in English, German, French, and 21 more
-- **OpenAI-Compatible** — Change one URL, keep your code
+- **24 Languages** — English, German, French, and 21 more
+- **OpenAI** — Works with OpenAI and compatible APIs (Azure, OpenRouter, Groq, Together AI, etc.)
+- **Anthropic** — Native Claude support, works with Claude Code
- **Self-Hosted** — Your servers, your data stays yours
-- **Open Source** — Apache 2.0 license, full transparency
+- **Open Source** — Apache 2.0 license
- **Dashboard** — See every protected request in real-time
-## How It Works
-
-```
-You send: "Write a follow-up email to Dr. Sarah Chen (sarah.chen@hospital.org)
- about next week's project meeting"
-
-LLM receives: "Write a follow-up email to [[PERSON_1]] ([[EMAIL_ADDRESS_1]])
- about next week's project meeting"
-
-LLM responds: "Dear [[PERSON_1]], Following up on our discussion..."
-
-You receive: "Dear Dr. Sarah Chen, Following up on our discussion..."
-```
-
-PasteGuard sits between your app and your provider. It's OpenAI-compatible — just change the base URL.
-
## Quick Start
```bash
docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
```
-Point your app to `http://localhost:3000/openai/v1` instead of `https://api.openai.com/v1`.
+Point your app to PasteGuard:
+
+| Provider | PasteGuard URL | Original URL |
+|----------|----------------|--------------|
+| OpenAI | `http://localhost:3000/openai/v1` | `https://api.openai.com/v1` |
+| Anthropic | `http://localhost:3000/anthropic` | `https://api.anthropic.com` |
Dashboard: [http://localhost:3000/dashboard](http://localhost:3000/dashboard)
## Integrations
-Works with any OpenAI-compatible tool:
+Works with OpenAI, Anthropic, and compatible tools:
- OpenAI SDK (Python/JS)
+- Anthropic SDK / Claude Code
- LangChain
- LlamaIndex
- Cursor
--- /dev/null
+---
+title: Anthropic
+description: POST /anthropic/v1/messages
+---
+
+# Anthropic Endpoint
+
+Generate messages with automatic PII and secrets protection using the Anthropic Messages API.
+
+```
+POST /anthropic/v1/messages
+```
+
+<Note>
+This endpoint supports both **mask mode** and **route mode**. Route mode requires a local provider with Anthropic API support (e.g., Ollama). The request format follows the [Anthropic Messages API](https://platform.claude.com/docs/en/api/messages).
+</Note>
+
+## Request
+
+```bash
+curl http://localhost:3000/anthropic/v1/messages \
+ -H "x-api-key: $ANTHROPIC_API_KEY" \
+ -H "anthropic-version: 2023-06-01" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "claude-sonnet-4-20250514",
+ "max_tokens": 1024,
+ "messages": [
+ {"role": "user", "content": "Hello"}
+ ]
+ }'
+```
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `model` | string | Yes | Model ID (e.g., `claude-sonnet-4-20250514`) |
+| `messages` | array | Yes | Conversation messages |
+| `max_tokens` | number | Yes | Maximum tokens to generate |
+| `stream` | boolean | No | Enable streaming |
+| `system` | string/array | No | System prompt |
+| `temperature` | number | No | Sampling temperature (0-1) |
+
+All [Anthropic Messages API](https://platform.claude.com/docs/en/api/messages) parameters are supported.
+
+## Response
+
+```json
+{
+ "id": "msg_abc123",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-4-20250514",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello! How can I help you today?"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 10,
+ "output_tokens": 15
+ }
+}
+```
+
+## Streaming
+
+Set `stream: true` for Server-Sent Events:
+
+<CodeGroup>
+
+```python Python
+from anthropic import Anthropic
+
+client = Anthropic(base_url="http://localhost:3000/anthropic")
+
+with client.messages.stream(
+ model="claude-sonnet-4-20250514",
+ max_tokens=1024,
+ messages=[{"role": "user", "content": "Write a haiku"}]
+) as stream:
+ for text in stream.text_stream:
+ print(text, end="")
+```
+
+```javascript JavaScript
+import Anthropic from '@anthropic-ai/sdk';
+
+const client = new Anthropic({
+ baseURL: 'http://localhost:3000/anthropic'
+});
+
+const stream = client.messages.stream({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1024,
+ messages: [{ role: 'user', content: 'Write a haiku' }]
+});
+
+for await (const event of stream) {
+ if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
+ process.stdout.write(event.delta.text);
+ }
+}
+```
+
+</CodeGroup>
+
+## Response Headers
+
+PasteGuard adds headers to indicate PII and secrets handling:
+
+| Header | Description |
+|--------|-------------|
+| `X-PasteGuard-Mode` | Current mode (`mask` or `route`) |
+| `X-PasteGuard-Provider` | Provider used (`anthropic` or `local`) |
+| `X-PasteGuard-PII-Detected` | `true` if PII was found |
+| `X-PasteGuard-PII-Masked` | `true` if PII was masked (mask mode only) |
+| `X-PasteGuard-Language` | Detected language code |
+| `X-PasteGuard-Language-Fallback` | `true` if configured language was not available |
+| `X-PasteGuard-Secrets-Detected` | `true` if secrets were found |
+| `X-PasteGuard-Secrets-Types` | Comma-separated list of detected secret types |
+| `X-PasteGuard-Secrets-Masked` | `true` if secrets were masked |
+
+## Content Types
+
+PasteGuard scans all text content in Anthropic requests:
+
+- **User messages** — String content or text blocks
+- **Assistant messages** — Including thinking blocks
+- **System prompts** — String or content block array
+- **Tool results** — Text content in tool responses
---
-title: Chat Completions
+title: OpenAI
description: POST /openai/v1/chat/completions
---
-# Chat Completions
+# OpenAI Endpoint
Generate chat completions with automatic PII and secrets protection.
| `temperature` | number | No | Sampling temperature (0-2) |
| `max_tokens` | number | No | Maximum tokens to generate |
-All OpenAI parameters are supported and forwarded to your provider.
+All [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat/create) parameters are supported.
## Response
## When to Use
- Simple setup without local infrastructure
-- Want to use any OpenAI-compatible provider while protecting PII
+- Want to use OpenAI, Anthropic, or compatible providers while protecting PII
## Configuration
PII stays on your network.
</Card>
<Card title="Request without PII" icon="server">
- Routed to **Configured Provider** (OpenAI, Azure, self-hosted, etc.)
+ Routed to **Configured Provider** (OpenAI, Anthropic, Azure, etc.)
Full provider performance.
</Card>
```
In route mode:
-- **No PII detected** → Request goes to configured provider (openai)
+- **No PII detected** → Request goes to configured provider (OpenAI or Anthropic)
- **PII detected** → Request goes to local provider
+<Note>
+For Anthropic requests, the local provider must support the Anthropic Messages API (e.g., Ollama with Anthropic API compatibility).
+</Note>
+
## Local Provider Setup
### Ollama
PasteGuard supports two provider types: configured providers (`providers`) and local provider (`local`).
-## Providers
+## OpenAI Provider
-Required for both modes. Any OpenAI-compatible endpoint works — cloud services (OpenAI, Azure, OpenRouter) or self-hosted (LiteLLM proxy, vLLM).
+Configure the OpenAI-compatible endpoint for `/openai/v1/*` requests.
```yaml
providers:
| `base_url` | API endpoint (any OpenAI-compatible URL) |
| `api_key` | Optional. Used if client doesn't send Authorization header |
-### Supported Endpoints
+### Compatible APIs
Any OpenAI-compatible API works:
base_url: https://api.groq.com/openai/v1
```
+## Anthropic Provider
+
+Configure the Anthropic endpoint for `/anthropic/v1/*` requests.
+
+```yaml
+providers:
+ anthropic:
+ base_url: https://api.anthropic.com
+ # api_key: ${ANTHROPIC_API_KEY} # Optional fallback
+```
+
+| Option | Description |
+|--------|-------------|
+| `base_url` | Anthropic API endpoint |
+| `api_key` | Optional. Used if client doesn't send `x-api-key` header |
+
## Local Provider
Required for route mode only. Your local LLM for PII requests.
## API Key Handling
-PasteGuard forwards your client's `Authorization` header to the configured provider. You can optionally set `api_key` in config as a fallback:
+PasteGuard forwards your client's authentication headers to the configured provider. You can optionally set `api_key` in config as a fallback:
```yaml
providers:
openai:
base_url: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY} # Used if client doesn't send auth
+
+ anthropic:
+ base_url: https://api.anthropic.com
+ api_key: ${ANTHROPIC_API_KEY} # Used if client doesn't send x-api-key
```
# Integrations
-PasteGuard works with any tool that supports the OpenAI API. Just change the base URL to point to PasteGuard.
+PasteGuard drops into your existing workflow. Point your tools to PasteGuard and every request gets PII protection automatically.
-## Cursor
+| Provider | PasteGuard URL |
+|----------|----------------|
+| OpenAI | `http://localhost:3000/openai/v1` |
+| Anthropic | `http://localhost:3000/anthropic` |
-In Cursor settings, configure a custom OpenAI base URL:
+## AI Coding Assistants
+
+### Claude Code
+
+Protect your prompts when using Claude Code. One environment variable, full PII protection:
+
+```bash
+ANTHROPIC_BASE_URL=http://localhost:3000/anthropic claude
+```
+
+Customer names, emails, and sensitive data in your codebase stay private.
+
+### Cursor
+
+Add PII protection to your Cursor workflow:
1. Open **Settings** → **Models**
2. Scroll to **API Keys** section
3. Enable **Override OpenAI Base URL** toggle
-4. Enter:
- ```
- http://localhost:3000/openai/v1
- ```
-5. Add your OpenAI API key above
+4. Enter: `http://localhost:3000/openai/v1`
+5. Add your OpenAI API key
-All requests from Cursor now go through PasteGuard with PII protection.
+Every code completion and chat message now goes through PasteGuard.
## Chat Interfaces
### Open WebUI
-In your Docker Compose or environment:
+Self-host your chat interface with built-in privacy:
-```yaml
-services:
- open-webui:
- environment:
- - OPENAI_API_BASE_URL=http://pasteguard:3000/openai/v1
- - OPENAI_API_KEY=your-key
+```bash
+OPENAI_API_BASE_URL=http://localhost:3000/openai/v1
```
-Or point Open WebUI to PasteGuard as an "OpenAI-compatible" connection.
+<Note>In Docker Compose, use the service name instead of `localhost` (e.g., `http://pasteguard:3000/openai/v1`).</Note>
### LibreChat
-Configure in your `librechat.yaml`:
+Add PasteGuard as a custom endpoint:
```yaml
version: 1.2.8
endpoints:
custom:
- name: "PasteGuard"
- apiKey: "${OPENAI_API_KEY}"
+ apiKey: "${OPENAI_API_KEY}" # Your API key, forwarded to provider
baseURL: "http://localhost:3000/openai/v1"
models:
default: ["gpt-5.2"]
- fetch: false
+ fetch: true
titleConvo: true
titleModel: "gpt-5.2"
```
-## Python / JavaScript
+## SDKs
+
+### Anthropic SDK
+
+<CodeGroup>
+
+```python Python
+from anthropic import Anthropic
+
+client = Anthropic(
+ base_url="http://localhost:3000/anthropic"
+)
+```
+
+```javascript JavaScript
+import Anthropic from '@anthropic-ai/sdk';
+
+const client = new Anthropic({
+ baseURL: 'http://localhost:3000/anthropic'
+});
+```
+
+</CodeGroup>
### OpenAI SDK
from openai import OpenAI
client = OpenAI(
- base_url="http://localhost:3000/openai/v1",
- api_key="your-key"
+ base_url="http://localhost:3000/openai/v1"
)
```
import OpenAI from 'openai';
const client = new OpenAI({
- baseURL: 'http://localhost:3000/openai/v1',
- apiKey: 'your-key'
+ baseURL: 'http://localhost:3000/openai/v1'
});
```
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
- base_url="http://localhost:3000/openai/v1",
- api_key="your-key"
+ base_url="http://localhost:3000/openai/v1"
)
```
llm = OpenAILike(
api_base="http://localhost:3000/openai/v1",
- api_key="your-key",
model="gpt-5.2",
is_chat_model=True
)
```
-## Environment Variable
+## Environment Variables
-Most tools respect the `OPENAI_API_BASE` or `OPENAI_BASE_URL` environment variable:
+Most tools respect the standard environment variables:
```bash
+# OpenAI-compatible tools
export OPENAI_API_BASE=http://localhost:3000/openai/v1
-export OPENAI_API_KEY=your-key
+
+# Anthropic tools
+export ANTHROPIC_BASE_URL=http://localhost:3000/anthropic
```
## Verify It Works
description: Privacy proxy for LLMs
---
-# What is PasteGuard?
+PasteGuard masks personal data and secrets before sending prompts to LLM providers.
-PasteGuard is an OpenAI-compatible proxy that protects personal data and secrets before sending to your provider (OpenAI, Azure, self-hosted, etc.).
-
-## The Problem
-
-When using LLM APIs, every prompt is sent to external servers - including customer names, emails, and sensitive business data. Many organizations have policies against sending PII to third-party AI services.
-
-## The Solution
+```
+You send: "Email Dr. Sarah Chen at sarah@hospital.org"
+LLM sees: "Email [[PERSON_1]] at [[EMAIL_ADDRESS_1]]"
+You get: Response with original names restored
+```
PasteGuard sits between your app and the LLM API:
Get early access to the browser extension
</Card>
-## Quick Example
-
-<Steps>
- <Step title="You send a prompt">
- ```
- Write a follow-up email to Dr. Sarah Chen (sarah.chen@hospital.org)
- ```
- </Step>
- <Step title="PasteGuard masks PII">
- Detected: `Dr. Sarah Chen` → `[[PERSON_1]]`, `sarah.chen@hospital.org` → `[[EMAIL_ADDRESS_1]]`
- </Step>
- <Step title="OpenAI receives">
- ```
- Write a follow-up email to [[PERSON_1]] ([[EMAIL_ADDRESS_1]])
- ```
- </Step>
- <Step title="You get the response (unmasked)">
- ```
- Dear Dr. Sarah Chen, Following up on our discussion...
- ```
- </Step>
-</Steps>
-
-<Note>The LLM never sees the real data. PII is masked before sending and restored in the response.</Note>
-
## Features
- **PII Detection** — Names, emails, phone numbers, credit cards, IBANs, and more
-- **Secrets Detection** — API keys, tokens, private keys, env credentials caught before they reach the LLM
+- **Secrets Detection** — API keys, tokens, private keys caught before they reach the LLM
- **Streaming Support** — Real-time unmasking as tokens arrive
-- **24 Languages** — Works in English, German, French, and 21 more
-- **OpenAI-Compatible** — Change one URL, keep your code
+- **24 Languages** — English, German, French, and 21 more
+- **OpenAI** — Works with OpenAI and compatible APIs (Azure, OpenRouter, Groq, Together AI, etc.)
+- **Anthropic** — Native Claude support, works with Claude Code
- **Self-Hosted** — Your servers, your data stays yours
-- **Open Source** — Apache 2.0 license, full transparency
+- **Open Source** — Apache 2.0 license
- **Dashboard** — See every protected request in real-time
## Next Steps
{
"group": "API Reference",
"pages": [
- "api-reference/chat-completions",
+ "api-reference/openai",
+ "api-reference/anthropic",
"api-reference/status",
"api-reference/dashboard-api"
]
api_key: z.string().optional(), // Optional fallback if client doesn't send auth header
});
+// Anthropic provider
+const AnthropicProviderSchema = z.object({
+ base_url: z.string().url().default("https://api.anthropic.com"),
+ api_key: z.string().optional(), // Optional fallback if client doesn't send auth header
+});
+
+const DEFAULT_WHITELIST = ["You are Claude Code, Anthropic's official CLI for Claude."];
+
const MaskingSchema = z.object({
show_markers: z.boolean().default(false),
marker_text: z.string().default("[protected]"),
- whitelist: z.array(z.string()).default([]),
+ whitelist: z
+ .array(z.string())
+ .default([])
+ .transform((arr) => [...DEFAULT_WHITELIST, ...arr]),
});
const LanguageEnum = z.enum(SUPPORTED_LANGUAGES);
// Providers
providers: z.object({
openai: OpenAIProviderSchema.default({}),
+ anthropic: AnthropicProviderSchema.default({}),
}),
// Local provider - only for route mode
local: LocalProviderSchema.optional(),
export type Config = z.infer<typeof ConfigSchema>;
export type OpenAIProviderConfig = z.infer<typeof OpenAIProviderSchema>;
+export type AnthropicProviderConfig = z.infer<typeof AnthropicProviderSchema>;
export type LocalProviderConfig = z.infer<typeof LocalProviderSchema>;
export type MaskingConfig = z.infer<typeof MaskingSchema>;
export type SecretsDetectionConfig = z.infer<typeof SecretsDetectionSchema>;
import { logger } from "hono/logger";
import { getConfig } from "./config";
import { getPIIDetector } from "./pii/detect";
+import { anthropicRoutes } from "./routes/anthropic";
import { dashboardRoutes } from "./routes/dashboard";
import { healthRoutes } from "./routes/health";
import { infoRoutes } from "./routes/info";
app.route("/", healthRoutes);
app.route("/", infoRoutes);
-app.route("/openai/v1", openaiRoutes);
+app.route("/openai", openaiRoutes);
+app.route("/anthropic", anthropicRoutes);
if (config.dashboard.enabled) {
app.route("/dashboard", dashboardRoutes);
Server: http://${host}:${port}
OpenAI API: http://${host}:${port}/openai/v1/chat/completions
+Anthropic: http://${host}:${port}/anthropic/v1/messages
Health: http://${host}:${port}/health
Info: http://${host}:${port}/info
Dashboard: http://${host}:${port}/dashboard
--- /dev/null
+import { describe, expect, test } from "bun:test";
+import type { PlaceholderContext } from "../../masking/context";
+import type {
+ AnthropicMessage,
+ AnthropicRequest,
+ AnthropicResponse,
+} from "../../providers/anthropic/types";
+import { anthropicExtractor } from "./anthropic";
+
+/** Helper to create a minimal request from messages */
+function createRequest(
+ messages: AnthropicMessage[],
+ system?: string | Array<{ type: "text"; text: string }>,
+): AnthropicRequest {
+ return { model: "claude-3-sonnet-20240229", max_tokens: 1024, messages, system };
+}
+
+describe("Anthropic Text Extractor", () => {
+ describe("extractTexts", () => {
+ test("extracts text from string content", () => {
+ const request = createRequest([
+ { role: "user", content: "Hello world" },
+ { role: "assistant", content: "Hi there" },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(2);
+ expect(spans[0]).toEqual({
+ text: "Hello world",
+ path: "messages[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "user",
+ });
+ expect(spans[1]).toEqual({
+ text: "Hi there",
+ path: "messages[1].content",
+ messageIndex: 1,
+ partIndex: 0,
+ role: "assistant",
+ });
+ });
+
+ test("extracts text from system string", () => {
+ const request = createRequest(
+ [{ role: "user", content: "Hello" }],
+ "You are a helpful assistant",
+ );
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(2);
+ // System comes first with messageIndex: -1
+ expect(spans[0]).toEqual({
+ text: "You are a helpful assistant",
+ path: "system",
+ messageIndex: -1,
+ partIndex: 0,
+ role: "system",
+ });
+ expect(spans[1]).toEqual({
+ text: "Hello",
+ path: "messages[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "user",
+ });
+ });
+
+ test("extracts text from system array", () => {
+ const request = createRequest(
+ [{ role: "user", content: "Hello" }],
+ [
+ { type: "text", text: "First system part" },
+ { type: "text", text: "Second system part" },
+ ],
+ );
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(3);
+ expect(spans[0]).toEqual({
+ text: "First system part",
+ path: "system[0].text",
+ messageIndex: -1,
+ partIndex: 0,
+ role: "system",
+ });
+ expect(spans[1]).toEqual({
+ text: "Second system part",
+ path: "system[1].text",
+ messageIndex: -1,
+ partIndex: 1,
+ role: "system",
+ });
+ expect(spans[2].role).toBe("user");
+ });
+
+ test("extracts text from text blocks in array content", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this image:" },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } },
+ { type: "text", text: "Be detailed" },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(2);
+ expect(spans[0]).toEqual({
+ text: "Describe this image:",
+ path: "messages[0].content[0].text",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "user",
+ });
+ expect(spans[1]).toEqual({
+ text: "Be detailed",
+ path: "messages[0].content[2].text",
+ messageIndex: 0,
+ partIndex: 2,
+ role: "user",
+ });
+ });
+
+ test("extracts text from thinking blocks", () => {
+ const request = createRequest([
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "Let me think about this..." },
+ { type: "text", text: "Here's my answer" },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(2);
+ expect(spans[0]).toEqual({
+ text: "Let me think about this...",
+ path: "messages[0].content[0].thinking",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "assistant",
+ });
+ expect(spans[1]).toEqual({
+ text: "Here's my answer",
+ path: "messages[0].content[1].text",
+ messageIndex: 0,
+ partIndex: 1,
+ role: "assistant",
+ });
+ });
+
+ test("extracts text from tool_result with string content", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tool_123", content: "Tool output here" }],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(1);
+ expect(spans[0]).toEqual({
+ text: "Tool output here",
+ path: "messages[0].content[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "tool",
+ });
+ });
+
+ test("extracts text from tool_result with array content, skipping images", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "tool_123",
+ content: [
+ { type: "text", text: "First text block" },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ { type: "text", text: "Second text block" },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(2);
+ expect(spans[0]).toEqual({
+ text: "First text block",
+ path: "messages[0].content[0].content[0].text",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 0,
+ role: "tool",
+ });
+ expect(spans[1]).toEqual({
+ text: "Second text block",
+ path: "messages[0].content[0].content[2].text",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 2,
+ role: "tool",
+ });
+ });
+
+ test("handles mixed string and array content", () => {
+ const request = createRequest([
+ { role: "user", content: "Simple message" },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Complex response" }],
+ },
+ { role: "user", content: "Another simple one" },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(3);
+ expect(spans[0].messageIndex).toBe(0);
+ expect(spans[0].role).toBe("user");
+ expect(spans[1].messageIndex).toBe(1);
+ expect(spans[1].role).toBe("assistant");
+ expect(spans[2].messageIndex).toBe(2);
+ expect(spans[2].role).toBe("user");
+ });
+
+ test("skips redacted_thinking blocks", () => {
+ const request = createRequest([
+ {
+ role: "assistant",
+ content: [
+ { type: "redacted_thinking", data: "encrypted_data" },
+ { type: "text", text: "Visible response" },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(1);
+ expect(spans[0].text).toBe("Visible response");
+ });
+
+ test("skips image blocks", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Look at this" },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(1);
+ expect(spans[0].text).toBe("Look at this");
+ });
+
+ test("skips tool_use blocks", () => {
+ const request = createRequest([
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Using a tool" },
+ { type: "tool_use", id: "tool_1", name: "calculator", input: { x: 5 } },
+ ],
+ },
+ ]);
+
+ const spans = anthropicExtractor.extractTexts(request);
+
+ expect(spans).toHaveLength(1);
+ expect(spans[0].text).toBe("Using a tool");
+ });
+
+ test("handles empty messages array", () => {
+ const request = createRequest([]);
+ const spans = anthropicExtractor.extractTexts(request);
+ expect(spans).toHaveLength(0);
+ });
+
+ test("handles empty content", () => {
+ const request = createRequest([{ role: "user", content: "" }]);
+ const spans = anthropicExtractor.extractTexts(request);
+ expect(spans).toHaveLength(0);
+ });
+ });
+
+ describe("applyMasked", () => {
+ test("applies masked text to string content", () => {
+ const request = createRequest([{ role: "user", content: "My email is john@example.com" }]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content",
+ maskedText: "My email is [[EMAIL_ADDRESS_1]]",
+ messageIndex: 0,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+
+ expect(result.messages[0].content).toBe("My email is [[EMAIL_ADDRESS_1]]");
+ });
+
+ test("applies masked text to system string", () => {
+ const request = createRequest(
+ [{ role: "user", content: "Hello" }],
+ "You are helping John Smith",
+ );
+
+ const maskedSpans = [
+ {
+ path: "system",
+ maskedText: "You are helping [[PERSON_1]]",
+ messageIndex: -1,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+
+ expect(result.system).toBe("You are helping [[PERSON_1]]");
+ });
+
+ test("applies masked text to system array", () => {
+ const request = createRequest(
+ [{ role: "user", content: "Hello" }],
+ [
+ { type: "text", text: "Help John Smith" },
+ { type: "text", text: "His email is john@test.com" },
+ ],
+ );
+
+ const maskedSpans = [
+ {
+ path: "system[0].text",
+ maskedText: "Help [[PERSON_1]]",
+ messageIndex: -1,
+ partIndex: 0,
+ },
+ {
+ path: "system[1].text",
+ maskedText: "His email is [[EMAIL_ADDRESS_1]]",
+ messageIndex: -1,
+ partIndex: 1,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+ const system = result.system as Array<{ type: string; text: string }>;
+
+ expect(system[0].text).toBe("Help [[PERSON_1]]");
+ expect(system[1].text).toBe("His email is [[EMAIL_ADDRESS_1]]");
+ });
+
+ test("applies masked text to text blocks", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Contact: john@example.com" },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ { type: "text", text: "Phone: 555-1234" },
+ ],
+ },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content[0].text",
+ maskedText: "Contact: [[EMAIL_ADDRESS_1]]",
+ messageIndex: 0,
+ partIndex: 0,
+ },
+ {
+ path: "messages[0].content[2].text",
+ maskedText: "Phone: [[PHONE_NUMBER_1]]",
+ messageIndex: 0,
+ partIndex: 2,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+ const content = result.messages[0].content as Array<{ type: string; text?: string }>;
+
+ expect(content[0].text).toBe("Contact: [[EMAIL_ADDRESS_1]]");
+ expect(content[1].type).toBe("image"); // Unchanged
+ expect(content[2].text).toBe("Phone: [[PHONE_NUMBER_1]]");
+ });
+
+ test("applies masked text to thinking blocks", () => {
+ const request = createRequest([
+ {
+ role: "assistant",
+ content: [{ type: "thinking", thinking: "User John Smith mentioned..." }],
+ },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content[0].thinking",
+ maskedText: "User [[PERSON_1]] mentioned...",
+ messageIndex: 0,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+ const content = result.messages[0].content as Array<{ type: string; thinking?: string }>;
+
+ expect(content[0].thinking).toBe("User [[PERSON_1]] mentioned...");
+ });
+
+ test("applies masked text to tool_result with string content", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: "tool_1", content: "Result for john@test.com" },
+ ],
+ },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content[0].content",
+ maskedText: "Result for [[EMAIL_ADDRESS_1]]",
+ messageIndex: 0,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+ const content = result.messages[0].content as Array<{ type: string; content?: string }>;
+
+ expect(content[0].content).toBe("Result for [[EMAIL_ADDRESS_1]]");
+ });
+
+ test("applies masked text to tool_result with array content, preserving images", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "tool_1",
+ content: [
+ { type: "text", text: "Screenshot of john@test.com profile" },
+ {
+ type: "image",
+ source: { type: "base64", media_type: "image/png", data: "abc123" },
+ },
+ { type: "text", text: "End of results" },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content[0].content[0].text",
+ maskedText: "Screenshot of [[EMAIL_ADDRESS_1]] profile",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 0,
+ },
+ {
+ path: "messages[0].content[0].content[2].text",
+ maskedText: "End of results",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 2,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+ const content = result.messages[0].content as Array<{
+ type: string;
+ content?: Array<{ type: string; text?: string; source?: unknown }>;
+ }>;
+
+ const nestedContent = content[0].content!;
+ expect(nestedContent).toHaveLength(3);
+ expect(nestedContent[0].type).toBe("text");
+ expect(nestedContent[0].text).toBe("Screenshot of [[EMAIL_ADDRESS_1]] profile");
+ expect(nestedContent[1].type).toBe("image");
+ expect(nestedContent[1].source).toEqual({
+ type: "base64",
+ media_type: "image/png",
+ data: "abc123",
+ });
+ expect(nestedContent[2].type).toBe("text");
+ expect(nestedContent[2].text).toBe("End of results");
+ });
+
+ test("preserves messages without masked spans", () => {
+ const request = createRequest([
+ { role: "user", content: "No PII here" },
+ { role: "assistant", content: "My email is john@example.com" },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[1].content",
+ maskedText: "My email is [[EMAIL_ADDRESS_1]]",
+ messageIndex: 1,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+
+ expect(result.messages[0].content).toBe("No PII here"); // Unchanged
+ expect(result.messages[1].content).toBe("My email is [[EMAIL_ADDRESS_1]]");
+ });
+
+ test("preserves message roles", () => {
+ const request = createRequest([
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi" },
+ ]);
+
+ const maskedSpans = [
+ { path: "messages[0].content", maskedText: "Masked", messageIndex: 0, partIndex: 0 },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+
+ expect(result.messages[0].role).toBe("user");
+ expect(result.messages[1].role).toBe("assistant");
+ });
+
+ test("creates deep copy of messages", () => {
+ const request = createRequest([
+ {
+ role: "user",
+ content: [{ type: "text", text: "Original" }],
+ },
+ ]);
+
+ const maskedSpans = [
+ {
+ path: "messages[0].content[0].text",
+ maskedText: "Masked",
+ messageIndex: 0,
+ partIndex: 0,
+ },
+ ];
+
+ const result = anthropicExtractor.applyMasked(request, maskedSpans);
+
+ // Original should be unchanged
+ expect((request.messages[0].content as Array<{ text: string }>)[0].text).toBe("Original");
+ expect((result.messages[0].content as Array<{ text: string }>)[0].text).toBe("Masked");
+ });
+ });
+
+ describe("unmaskResponse", () => {
+ test("unmasks placeholders in response content", () => {
+ const response: AnthropicResponse = {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [{ type: "text", text: "Hello [[PERSON_1]], your email is [[EMAIL_ADDRESS_1]]" }],
+ model: "claude-3-sonnet-20240229",
+ stop_reason: "end_turn",
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: {
+ "[[PERSON_1]]": "John",
+ "[[EMAIL_ADDRESS_1]]": "john@example.com",
+ },
+ reverseMapping: {
+ John: "[[PERSON_1]]",
+ "john@example.com": "[[EMAIL_ADDRESS_1]]",
+ },
+ counters: { PERSON: 1, EMAIL_ADDRESS: 1 },
+ };
+
+ const result = anthropicExtractor.unmaskResponse(response, context);
+
+ expect((result.content[0] as { text: string }).text).toBe(
+ "Hello John, your email is john@example.com",
+ );
+ });
+
+ test("applies formatValue function when provided", () => {
+ const response: AnthropicResponse = {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [{ type: "text", text: "Hello [[PERSON_1]]" }],
+ model: "claude-3-sonnet-20240229",
+ stop_reason: "end_turn",
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: { "[[PERSON_1]]": "John" },
+ reverseMapping: { John: "[[PERSON_1]]" },
+ counters: { PERSON: 1 },
+ };
+
+ const result = anthropicExtractor.unmaskResponse(
+ response,
+ context,
+ (val) => `[protected]${val}`,
+ );
+
+ expect((result.content[0] as { text: string }).text).toBe("Hello [protected]John");
+ });
+
+ test("handles multiple text blocks", () => {
+ const response: AnthropicResponse = {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [
+ { type: "text", text: "First: [[PERSON_1]]" },
+ { type: "text", text: "Second: [[PERSON_1]]" },
+ ],
+ model: "claude-3-sonnet-20240229",
+ stop_reason: "end_turn",
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: { "[[PERSON_1]]": "Alice" },
+ reverseMapping: { Alice: "[[PERSON_1]]" },
+ counters: { PERSON: 1 },
+ };
+
+ const result = anthropicExtractor.unmaskResponse(response, context);
+
+ expect((result.content[0] as { text: string }).text).toBe("First: Alice");
+ expect((result.content[1] as { text: string }).text).toBe("Second: Alice");
+ });
+
+ test("preserves non-text blocks", () => {
+ const response: AnthropicResponse = {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [
+ { type: "text", text: "[[PERSON_1]]" },
+ { type: "tool_use", id: "tool_1", name: "calculator", input: { x: 5 } },
+ ],
+ model: "claude-3-sonnet-20240229",
+ stop_reason: "end_turn",
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: { "[[PERSON_1]]": "Bob" },
+ reverseMapping: { Bob: "[[PERSON_1]]" },
+ counters: { PERSON: 1 },
+ };
+
+ const result = anthropicExtractor.unmaskResponse(response, context);
+
+ expect((result.content[0] as { text: string }).text).toBe("Bob");
+ expect(result.content[1].type).toBe("tool_use");
+ });
+
+ test("preserves response structure", () => {
+ const response: AnthropicResponse = {
+ id: "resp_abc",
+ type: "message",
+ role: "assistant",
+ content: [{ type: "text", text: "Test" }],
+ model: "claude-3-opus",
+ stop_reason: "max_tokens",
+ stop_sequence: "END",
+ usage: { input_tokens: 100, output_tokens: 50 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: {},
+ reverseMapping: {},
+ counters: {},
+ };
+
+ const result = anthropicExtractor.unmaskResponse(response, context);
+
+ expect(result.id).toBe("resp_abc");
+ expect(result.model).toBe("claude-3-opus");
+ expect(result.stop_reason).toBe("max_tokens");
+ expect(result.stop_sequence).toBe("END");
+ expect(result.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
+ });
+
+ test("handles empty mapping", () => {
+ const response: AnthropicResponse = {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [{ type: "text", text: "No placeholders here" }],
+ model: "claude-3-sonnet-20240229",
+ stop_reason: "end_turn",
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ };
+
+ const context: PlaceholderContext = {
+ mapping: {},
+ reverseMapping: {},
+ counters: {},
+ };
+
+ const result = anthropicExtractor.unmaskResponse(response, context);
+
+ expect((result.content[0] as { text: string }).text).toBe("No placeholders here");
+ });
+ });
+});
--- /dev/null
+/**
+ * Anthropic request extractor for format-agnostic masking
+ *
+ * Extracts text from Anthropic request structures and handles unmasking
+ * in responses. Anthropic has different content types:
+ * - String content (simple)
+ * - Content blocks array (text, image, tool_use, tool_result, thinking)
+ * - System prompt (string or content blocks) - SEPARATE from messages
+ *
+ * System spans use messageIndex -1 to distinguish from message spans.
+ */
+
+import type { PlaceholderContext } from "../../masking/context";
+import type {
+ AnthropicRequest,
+ AnthropicResponse,
+ ContentBlock,
+ TextBlock,
+ ThinkingBlock,
+ ToolResultBlock,
+} from "../../providers/anthropic/types";
+import type { MaskedSpan, RequestExtractor, TextSpan } from "../types";
+
+/** System content uses messageIndex -1 */
+const SYSTEM_MESSAGE_INDEX = -1;
+
+/**
+ * Extract text from a single content block
+ */
+function extractBlockText(block: ContentBlock): string {
+ if (block.type === "text") {
+ return (block as TextBlock).text;
+ }
+ if (block.type === "thinking") {
+ return (block as ThinkingBlock).thinking;
+ }
+ if (block.type === "redacted_thinking") {
+ return "";
+ }
+ if (block.type === "tool_result") {
+ const toolResult = block as ToolResultBlock;
+ if (typeof toolResult.content === "string") {
+ return toolResult.content;
+ }
+ if (Array.isArray(toolResult.content)) {
+ return toolResult.content.map(extractBlockText).filter(Boolean).join("\n");
+ }
+ }
+ return "";
+}
+
+/**
+ * Extract text from content (string or block array)
+ */
+export function extractAnthropicTextContent(content: string | ContentBlock[] | undefined): string {
+ if (!content) return "";
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content.map(extractBlockText).filter(Boolean).join("\n");
+ }
+ return "";
+}
+
+/**
+ * Extract text from system prompt (for logging/debugging)
+ */
+export function extractSystemText(system: string | ContentBlock[] | undefined): string {
+ if (!system) return "";
+ if (typeof system === "string") return system;
+ return extractAnthropicTextContent(system);
+}
+
+/**
+ * Anthropic request extractor
+ *
+ * Extracts text from both system (messageIndex: -1) and messages.
+ */
+export const anthropicExtractor: RequestExtractor<AnthropicRequest, AnthropicResponse> = {
+ extractTexts(request: AnthropicRequest): TextSpan[] {
+ const spans: TextSpan[] = [];
+
+ // Extract system text (messageIndex: -1)
+ if (request.system) {
+ if (typeof request.system === "string") {
+ if (request.system) {
+ spans.push({
+ text: request.system,
+ path: "system",
+ messageIndex: SYSTEM_MESSAGE_INDEX,
+ partIndex: 0,
+ role: "system",
+ });
+ }
+ } else if (Array.isArray(request.system)) {
+ for (let partIdx = 0; partIdx < request.system.length; partIdx++) {
+ const block = request.system[partIdx];
+ const text = extractBlockText(block);
+ if (text) {
+ const pathSuffix =
+ block.type === "text" ? "text" : block.type === "thinking" ? "thinking" : null;
+ if (pathSuffix) {
+ spans.push({
+ text,
+ path: `system[${partIdx}].${pathSuffix}`,
+ messageIndex: SYSTEM_MESSAGE_INDEX,
+ partIndex: partIdx,
+ role: "system",
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // Extract message text
+ for (let msgIdx = 0; msgIdx < request.messages.length; msgIdx++) {
+ const msg = request.messages[msgIdx];
+
+ if (typeof msg.content === "string") {
+ if (msg.content) {
+ spans.push({
+ text: msg.content,
+ path: `messages[${msgIdx}].content`,
+ messageIndex: msgIdx,
+ partIndex: 0,
+ role: msg.role,
+ });
+ }
+ } else if (Array.isArray(msg.content)) {
+ for (let partIdx = 0; partIdx < msg.content.length; partIdx++) {
+ const block = msg.content[partIdx];
+
+ if (block.type === "text") {
+ const text = (block as TextBlock).text;
+ if (text) {
+ spans.push({
+ text,
+ path: `messages[${msgIdx}].content[${partIdx}].text`,
+ messageIndex: msgIdx,
+ partIndex: partIdx,
+ role: msg.role,
+ });
+ }
+ } else if (block.type === "thinking") {
+ const text = (block as ThinkingBlock).thinking;
+ if (text) {
+ spans.push({
+ text,
+ path: `messages[${msgIdx}].content[${partIdx}].thinking`,
+ messageIndex: msgIdx,
+ partIndex: partIdx,
+ role: msg.role,
+ });
+ }
+ } else if (block.type === "tool_result") {
+ const toolResult = block as ToolResultBlock;
+ if (typeof toolResult.content === "string") {
+ if (toolResult.content) {
+ spans.push({
+ text: toolResult.content,
+ path: `messages[${msgIdx}].content[${partIdx}].content`,
+ messageIndex: msgIdx,
+ partIndex: partIdx,
+ role: "tool",
+ });
+ }
+ } else if (Array.isArray(toolResult.content)) {
+ for (let nestedIdx = 0; nestedIdx < toolResult.content.length; nestedIdx++) {
+ const nestedBlock = toolResult.content[nestedIdx];
+ if (nestedBlock.type === "text") {
+ const text = (nestedBlock as TextBlock).text;
+ if (text) {
+ spans.push({
+ text,
+ path: `messages[${msgIdx}].content[${partIdx}].content[${nestedIdx}].text`,
+ messageIndex: msgIdx,
+ partIndex: partIdx,
+ nestedPartIndex: nestedIdx,
+ role: "tool",
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return spans;
+ },
+
+ applyMasked(request: AnthropicRequest, maskedSpans: MaskedSpan[]): AnthropicRequest {
+ // Separate system spans from message spans
+ const systemSpans = maskedSpans.filter((s) => s.messageIndex === SYSTEM_MESSAGE_INDEX);
+ const messageSpans = maskedSpans.filter((s) => s.messageIndex >= 0);
+
+ // Apply system masking
+ let maskedSystem = request.system;
+ if (systemSpans.length > 0 && request.system) {
+ if (typeof request.system === "string") {
+ const span = systemSpans.find((s) => s.partIndex === 0);
+ if (span) {
+ maskedSystem = span.maskedText;
+ }
+ } else if (Array.isArray(request.system)) {
+ maskedSystem = request.system.map((block, partIdx) => {
+ const span = systemSpans.find((s) => s.partIndex === partIdx);
+ if (!span) return block;
+
+ if (block.type === "text") {
+ return { ...block, text: span.maskedText };
+ }
+ if (block.type === "thinking") {
+ return { ...block, thinking: span.maskedText };
+ }
+ return block;
+ });
+ }
+ }
+
+ // Apply message masking
+ const maskedMessages = request.messages.map((msg, msgIdx) => {
+ const msgSpans = messageSpans.filter((s) => s.messageIndex === msgIdx);
+ if (msgSpans.length === 0) return msg;
+
+ if (typeof msg.content === "string") {
+ const span = msgSpans.find((s) => s.partIndex === 0);
+ if (span) {
+ return { ...msg, content: span.maskedText };
+ }
+ return msg;
+ }
+
+ if (Array.isArray(msg.content)) {
+ const maskedContent = msg.content.map((block, partIdx) => {
+ const partSpans = msgSpans.filter((s) => s.partIndex === partIdx);
+ if (partSpans.length === 0) return block;
+
+ if (block.type === "text") {
+ const span = partSpans.find((s) => s.nestedPartIndex === undefined);
+ if (span) return { ...block, text: span.maskedText };
+ }
+ if (block.type === "thinking") {
+ const span = partSpans.find((s) => s.nestedPartIndex === undefined);
+ if (span) return { ...block, thinking: span.maskedText };
+ }
+ if (block.type === "tool_result") {
+ const toolResult = block as ToolResultBlock;
+ if (typeof toolResult.content === "string") {
+ const span = partSpans.find((s) => s.nestedPartIndex === undefined);
+ if (span) return { ...block, content: span.maskedText };
+ }
+ if (Array.isArray(toolResult.content)) {
+ const maskedNestedContent = toolResult.content.map((nestedBlock, nestedIdx) => {
+ const span = partSpans.find((s) => s.nestedPartIndex === nestedIdx);
+ if (span && nestedBlock.type === "text") {
+ return { ...nestedBlock, text: span.maskedText };
+ }
+ return nestedBlock;
+ });
+ return { ...block, content: maskedNestedContent };
+ }
+ }
+ return block;
+ });
+ return { ...msg, content: maskedContent };
+ }
+
+ return msg;
+ });
+
+ return { ...request, system: maskedSystem, messages: maskedMessages };
+ },
+
+ unmaskResponse(
+ response: AnthropicResponse,
+ context: PlaceholderContext,
+ formatValue?: (original: string) => string,
+ ): AnthropicResponse {
+ const unmaskText = (text: string): string => {
+ let result = text;
+ for (const [placeholder, original] of Object.entries(context.mapping)) {
+ const value = formatValue ? formatValue(original) : original;
+ result = result.replaceAll(placeholder, value);
+ }
+ return result;
+ };
+
+ return {
+ ...response,
+ content: response.content.map((block) => {
+ if (block.type === "text") {
+ return { ...block, text: unmaskText((block as TextBlock).text) };
+ }
+ return block;
+ }),
+ };
+ },
+};
--- /dev/null
+/**
+ * Anthropic client - simple functions for Anthropic Messages API
+ */
+
+import type { AnthropicProviderConfig } from "../../config";
+import { REQUEST_TIMEOUT_MS } from "../../constants/timeouts";
+import { ProviderError } from "../errors";
+import type { AnthropicRequest, AnthropicResponse } from "./types";
+
+export const ANTHROPIC_VERSION = "2023-06-01";
+const DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com";
+
+/**
+ * Result from Anthropic client
+ */
+export type AnthropicResult =
+ | {
+ isStreaming: true;
+ response: ReadableStream<Uint8Array>;
+ model: string;
+ }
+ | {
+ isStreaming: false;
+ response: AnthropicResponse;
+ model: string;
+ };
+
+/**
+ * Client headers forwarded from the request
+ */
+export interface AnthropicClientHeaders {
+ apiKey?: string;
+ authorization?: string;
+ beta?: string;
+}
+
+/**
+ * Call Anthropic Messages API
+ *
+ * Transparent header forwarding - all auth headers from client are passed through.
+ * Config api_key is only used as fallback when no client auth headers present.
+ */
+export async function callAnthropic(
+ request: AnthropicRequest,
+ config: AnthropicProviderConfig,
+ clientHeaders?: AnthropicClientHeaders,
+): Promise<AnthropicResult> {
+ const isStreaming = request.stream ?? false;
+ const baseUrl = (config.base_url || DEFAULT_ANTHROPIC_URL).replace(/\/$/, "");
+
+ const headers: Record<string, string> = {
+ "Content-Type": "application/json",
+ "anthropic-version": ANTHROPIC_VERSION,
+ };
+
+ // Transparent auth forwarding - client headers take priority
+ if (clientHeaders?.apiKey) {
+ headers["x-api-key"] = clientHeaders.apiKey;
+ } else if (clientHeaders?.authorization) {
+ headers.Authorization = clientHeaders.authorization;
+ } else if (config.api_key) {
+ // Fallback to config only if no client auth
+ headers["x-api-key"] = config.api_key;
+ }
+
+ // Forward client's beta header unchanged
+ if (clientHeaders?.beta) {
+ headers["anthropic-beta"] = clientHeaders.beta;
+ }
+
+ const response = await fetch(`${baseUrl}/v1/messages`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(request),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new ProviderError(response.status, response.statusText, await response.text());
+ }
+
+ if (isStreaming) {
+ if (!response.body) {
+ throw new Error("No response body for streaming request");
+ }
+ return { response: response.body, isStreaming: true, model: request.model };
+ }
+
+ return { response: await response.json(), isStreaming: false, model: request.model };
+}
+
+/**
+ * Get Anthropic provider info for /info endpoint
+ */
+export function getAnthropicInfo(config: AnthropicProviderConfig): { baseUrl: string } {
+ return {
+ baseUrl: config.base_url || DEFAULT_ANTHROPIC_URL,
+ };
+}
--- /dev/null
+import { describe, expect, test } from "bun:test";
+import type { MaskingConfig } from "../../config";
+import { createMaskingContext } from "../../pii/mask";
+import { createAnthropicUnmaskingStream } from "./stream-transformer";
+
+const defaultConfig: MaskingConfig = {
+ show_markers: false,
+ marker_text: "[protected]",
+ whitelist: [],
+};
+
+/**
+ * Helper to create a ReadableStream from Anthropic SSE data
+ */
+function createSSEStream(chunks: string[]): ReadableStream<Uint8Array> {
+ const encoder = new TextEncoder();
+ let index = 0;
+
+ return new ReadableStream({
+ pull(controller) {
+ if (index < chunks.length) {
+ controller.enqueue(encoder.encode(chunks[index]));
+ index++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+}
+
+/**
+ * Helper to consume a stream and return all chunks as string
+ */
+async function consumeStream(stream: ReadableStream<Uint8Array>): Promise<string> {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let result = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ result += decoder.decode(value, { stream: true });
+ }
+
+ return result;
+}
+
+/**
+ * Helper to create Anthropic SSE format
+ */
+function createAnthropicEvent(type: string, data: object): string {
+ return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
+}
+
+function createTextDelta(text: string, index = 0): string {
+ return createAnthropicEvent("content_block_delta", {
+ type: "content_block_delta",
+ index,
+ delta: { type: "text_delta", text },
+ });
+}
+
+describe("createAnthropicUnmaskingStream", () => {
+ test("unmasks complete placeholder in single chunk", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[EMAIL_ADDRESS_1]]"] = "test@test.com";
+
+ const sseData = createTextDelta("Hello [[EMAIL_ADDRESS_1]]!");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("Hello test@test.com!");
+ });
+
+ test("handles message_start event", async () => {
+ const context = createMaskingContext();
+
+ const messageStart = createAnthropicEvent("message_start", {
+ type: "message_start",
+ message: {
+ id: "msg_123",
+ type: "message",
+ role: "assistant",
+ content: [],
+ model: "claude-3-sonnet",
+ },
+ });
+ const source = createSSEStream([messageStart]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("message_start");
+ expect(result).toContain("msg_123");
+ });
+
+ test("passes through non-text-delta events unchanged", async () => {
+ const context = createMaskingContext();
+
+ const contentBlockStart = createAnthropicEvent("content_block_start", {
+ type: "content_block_start",
+ index: 0,
+ content_block: { type: "text", text: "" },
+ });
+ const source = createSSEStream([contentBlockStart]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("content_block_start");
+ });
+
+ test("buffers partial placeholder across chunks", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[EMAIL_ADDRESS_1]]"] = "a@b.com";
+
+ // Split placeholder across chunks
+ const chunks = [createTextDelta("Hello [[EMAIL_"), createTextDelta("ADDRESS_1]] world")];
+ const source = createSSEStream(chunks);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ // Should eventually contain the unmasked email
+ expect(result).toContain("a@b.com");
+ });
+
+ test("flushes remaining buffer on stream end", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[EMAIL_ADDRESS_1]]"] = "test@test.com";
+
+ const chunks = [createTextDelta("Contact [[EMAIL_ADDRESS_1]]")];
+ const source = createSSEStream(chunks);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("test@test.com");
+ });
+
+ test("handles multiple placeholders in stream", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[PERSON_1]]"] = "John";
+ context.mapping["[[EMAIL_ADDRESS_1]]"] = "john@test.com";
+
+ const sseData = createTextDelta("[[PERSON_1]]: [[EMAIL_ADDRESS_1]]");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("John");
+ expect(result).toContain("john@test.com");
+ });
+
+ test("handles empty stream", async () => {
+ const context = createMaskingContext();
+ const source = createSSEStream([]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toBe("");
+ });
+
+ test("passes through malformed data", async () => {
+ const context = createMaskingContext();
+
+ const chunks = [`event: content_block_delta\ndata: not-json\n\n`];
+ const source = createSSEStream(chunks);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("not-json");
+ });
+
+ test("handles message_stop event", async () => {
+ const context = createMaskingContext();
+
+ const messageStop = createAnthropicEvent("message_stop", { type: "message_stop" });
+ const source = createSSEStream([messageStop]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("message_stop");
+ });
+
+ test("handles ping events", async () => {
+ const context = createMaskingContext();
+
+ const ping = createAnthropicEvent("ping", { type: "ping" });
+ const source = createSSEStream([ping]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("ping");
+ });
+
+ test("unmasks secrets context", async () => {
+ const piiContext = createMaskingContext();
+ const secretsContext = createMaskingContext();
+ secretsContext.mapping["[[SECRET_OPENSSH_PRIVATE_KEY_1]]"] = "secret-key-value";
+
+ const sseData = createTextDelta("Key: [[SECRET_OPENSSH_PRIVATE_KEY_1]]");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(
+ source,
+ piiContext,
+ defaultConfig,
+ secretsContext,
+ );
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("secret-key-value");
+ });
+
+ test("unmasks both PII and secrets", async () => {
+ const piiContext = createMaskingContext();
+ piiContext.mapping["[[PERSON_1]]"] = "Alice";
+
+ const secretsContext = createMaskingContext();
+ secretsContext.mapping["[[SECRET_API_KEY_1]]"] = "sk-12345";
+
+ const sseData = createTextDelta("[[PERSON_1]]'s key: [[SECRET_API_KEY_1]]");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(
+ source,
+ piiContext,
+ defaultConfig,
+ secretsContext,
+ );
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("Alice");
+ expect(result).toContain("sk-12345");
+ });
+
+ test("handles line buffering for split chunks", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[PERSON_1]]"] = "Bob";
+
+ // Simulate a chunk that splits in the middle of the SSE format
+ const chunks = [
+ `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi `,
+ `[[PERSON_1]]"}}\n\n`,
+ ];
+ const source = createSSEStream(chunks);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("Bob");
+ });
+
+ test("handles tool_use deltas (input_json_delta)", async () => {
+ const context = createMaskingContext();
+
+ const toolUseDelta = createAnthropicEvent("content_block_delta", {
+ type: "content_block_delta",
+ index: 0,
+ delta: { type: "input_json_delta", partial_json: '{"arg": "value"}' },
+ });
+ const source = createSSEStream([toolUseDelta]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ // input_json_delta should pass through unchanged
+ expect(result).toContain("input_json_delta");
+ expect(result).toContain("arg");
+ expect(result).toContain("value");
+ });
+
+ test("handles content_block_stop events", async () => {
+ const context = createMaskingContext();
+
+ const blockStop = createAnthropicEvent("content_block_stop", {
+ type: "content_block_stop",
+ index: 0,
+ });
+ const source = createSSEStream([blockStop]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("content_block_stop");
+ });
+
+ test("handles message_delta events", async () => {
+ const context = createMaskingContext();
+
+ const messageDelta = createAnthropicEvent("message_delta", {
+ type: "message_delta",
+ delta: { stop_reason: "end_turn", stop_sequence: null },
+ usage: { output_tokens: 42 },
+ });
+ const source = createSSEStream([messageDelta]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("message_delta");
+ expect(result).toContain("end_turn");
+ });
+
+ test("preserves event type lines", async () => {
+ const context = createMaskingContext();
+
+ const sseData = createTextDelta("Hello world");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("event: content_block_delta");
+ });
+
+ test("handles undefined pii context", async () => {
+ const sseData = createTextDelta("Plain text without placeholders");
+ const source = createSSEStream([sseData]);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, undefined, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("Plain text without placeholders");
+ });
+
+ test("handles multiple consecutive text deltas", async () => {
+ const context = createMaskingContext();
+ context.mapping["[[PERSON_1]]"] = "Jane";
+
+ const chunks = [
+ createTextDelta("Hello "),
+ createTextDelta("[[PERSON_1]]"),
+ createTextDelta("! How are you?"),
+ ];
+ const source = createSSEStream(chunks);
+
+ const unmaskedStream = createAnthropicUnmaskingStream(source, context, defaultConfig);
+ const result = await consumeStream(unmaskedStream);
+
+ expect(result).toContain("Jane");
+ expect(result).toContain("How are you?");
+ });
+});
--- /dev/null
+/**
+ * Anthropic SSE stream transformer for unmasking PII and secrets
+ *
+ * Anthropic uses a different SSE format than OpenAI:
+ * - event: message_start / content_block_start / content_block_delta / etc.
+ * - data: {...}
+ *
+ * Text content comes in content_block_delta events with delta.type === "text_delta"
+ */
+
+import type { MaskingConfig } from "../../config";
+import type { PlaceholderContext } from "../../masking/context";
+import { flushMaskingBuffer, unmaskStreamChunk } from "../../pii/mask";
+import { flushSecretsMaskingBuffer, unmaskSecretsStreamChunk } from "../../secrets/mask";
+import type { ContentBlockDeltaEvent, TextDelta } from "./types";
+
+/**
+ * Creates a transform stream that unmasks Anthropic SSE content
+ */
+export function createAnthropicUnmaskingStream(
+ source: ReadableStream<Uint8Array>,
+ piiContext: PlaceholderContext | undefined,
+ config: MaskingConfig,
+ secretsContext?: PlaceholderContext,
+): ReadableStream<Uint8Array> {
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+ let piiBuffer = "";
+ let secretsBuffer = "";
+ let lineBuffer = "";
+
+ return new ReadableStream({
+ async start(controller) {
+ const reader = source.getReader();
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ // Flush remaining buffers
+ let flushed = "";
+
+ if (piiBuffer && piiContext) {
+ flushed = flushMaskingBuffer(piiBuffer, piiContext, config);
+ } else if (piiBuffer) {
+ flushed = piiBuffer;
+ }
+
+ if (secretsBuffer && secretsContext) {
+ flushed += flushSecretsMaskingBuffer(secretsBuffer, secretsContext);
+ } else if (secretsBuffer) {
+ flushed += secretsBuffer;
+ }
+
+ // Send flushed content as final text delta
+ if (flushed) {
+ const finalEvent: ContentBlockDeltaEvent = {
+ type: "content_block_delta",
+ index: 0,
+ delta: { type: "text_delta", text: flushed },
+ };
+ controller.enqueue(
+ encoder.encode(
+ `event: content_block_delta\ndata: ${JSON.stringify(finalEvent)}\n\n`,
+ ),
+ );
+ }
+
+ controller.close();
+ break;
+ }
+
+ lineBuffer += decoder.decode(value, { stream: true });
+ const lines = lineBuffer.split("\n");
+ lineBuffer = lines.pop() || "";
+
+ for (const line of lines) {
+ // Pass through event type lines
+ if (line.startsWith("event: ")) {
+ controller.enqueue(encoder.encode(`${line}\n`));
+ continue;
+ }
+
+ // Process data lines
+ if (line.startsWith("data: ")) {
+ const data = line.slice(6);
+
+ try {
+ const parsed = JSON.parse(data) as { type: string; delta?: { type: string } };
+
+ // Only process text deltas
+ if (parsed.type === "content_block_delta" && parsed.delta?.type === "text_delta") {
+ const event = parsed as ContentBlockDeltaEvent;
+ const textDelta = event.delta as TextDelta;
+ let processedText = textDelta.text;
+
+ // Unmask PII
+ if (piiContext && processedText) {
+ const { output, remainingBuffer } = unmaskStreamChunk(
+ piiBuffer,
+ processedText,
+ piiContext,
+ config,
+ );
+ piiBuffer = remainingBuffer;
+ processedText = output;
+ }
+
+ // Unmask secrets
+ if (secretsContext && processedText) {
+ const { output, remainingBuffer } = unmaskSecretsStreamChunk(
+ secretsBuffer,
+ processedText,
+ secretsContext,
+ );
+ secretsBuffer = remainingBuffer;
+ processedText = output;
+ }
+
+ // Only emit if we have content
+ if (processedText) {
+ const modifiedEvent = {
+ ...parsed,
+ delta: { ...textDelta, text: processedText },
+ };
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(modifiedEvent)}\n`));
+ }
+ } else {
+ // Pass through other events unchanged
+ controller.enqueue(encoder.encode(`data: ${data}\n`));
+ }
+ } catch {
+ // Pass through unparseable data
+ controller.enqueue(encoder.encode(`${line}\n`));
+ }
+ continue;
+ }
+
+ // Pass through empty lines and other content
+ if (line.trim() === "") {
+ controller.enqueue(encoder.encode("\n"));
+ } else {
+ controller.enqueue(encoder.encode(`${line}\n`));
+ }
+ }
+ }
+ } catch (error) {
+ controller.error(error);
+ } finally {
+ reader.releaseLock();
+ }
+ },
+ });
+}
--- /dev/null
+/**
+ * Anthropic API Types
+ * Based on: https://docs.anthropic.com/en/api/messages
+ */
+
+import { z } from "zod";
+
+// Content block types
+export const TextBlockSchema = z.object({
+ type: z.literal("text"),
+ text: z.string(),
+});
+
+export const ImageBlockSchema = z.object({
+ type: z.literal("image"),
+ source: z.object({
+ type: z.enum(["base64", "url"]),
+ media_type: z.string().optional(),
+ data: z.string().optional(),
+ url: z.string().optional(),
+ }),
+});
+
+export const ToolUseBlockSchema = z.object({
+ type: z.literal("tool_use"),
+ id: z.string(),
+ name: z.string(),
+ input: z.record(z.unknown()),
+});
+
+export const ThinkingBlockSchema = z.object({
+ type: z.literal("thinking"),
+ thinking: z.string(),
+ signature: z.string().optional(),
+});
+
+export const RedactedThinkingBlockSchema = z.object({
+ type: z.literal("redacted_thinking"),
+ data: z.string(),
+});
+
+// ToolResultBlock can contain nested content blocks, so we define it with z.any() for content
+// and provide proper type separately
+export const ToolResultBlockSchema = z.object({
+ type: z.literal("tool_result"),
+ tool_use_id: z.string(),
+ content: z.union([z.string(), z.array(z.any())]),
+ is_error: z.boolean().optional(),
+});
+
+export const ContentBlockSchema = z.discriminatedUnion("type", [
+ TextBlockSchema,
+ ImageBlockSchema,
+ ToolUseBlockSchema,
+ ToolResultBlockSchema,
+ ThinkingBlockSchema,
+ RedactedThinkingBlockSchema,
+]);
+
+// Message and request types
+export const AnthropicMessageSchema = z.object({
+ role: z.enum(["user", "assistant"]),
+ content: z.union([z.string(), z.array(ContentBlockSchema)]),
+});
+
+export const ToolSchema = z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ input_schema: z.object({
+ type: z.literal("object"),
+ properties: z.record(z.unknown()).optional(),
+ required: z.array(z.string()).optional(),
+ }),
+});
+
+export const AnthropicRequestSchema = z
+ .object({
+ model: z.string(),
+ messages: z.array(AnthropicMessageSchema).min(1),
+ max_tokens: z.number(),
+ system: z.union([z.string(), z.array(ContentBlockSchema)]).optional(),
+ tools: z.array(ToolSchema).optional(),
+ tool_choice: z
+ .object({
+ type: z.enum(["auto", "any", "tool"]),
+ name: z.string().optional(),
+ })
+ .optional(),
+ stream: z.boolean().optional(),
+ temperature: z.number().optional(),
+ top_p: z.number().optional(),
+ top_k: z.number().optional(),
+ stop_sequences: z.array(z.string()).optional(),
+ metadata: z.object({ user_id: z.string().optional() }).optional(),
+ })
+ .passthrough();
+
+export const AnthropicResponseSchema = z.object({
+ id: z.string(),
+ type: z.literal("message"),
+ role: z.literal("assistant"),
+ content: z.array(ContentBlockSchema),
+ model: z.string(),
+ stop_reason: z.enum(["end_turn", "max_tokens", "stop_sequence", "tool_use"]).nullable(),
+ stop_sequence: z.string().nullable(),
+ usage: z.object({
+ input_tokens: z.number(),
+ output_tokens: z.number(),
+ cache_creation_input_tokens: z.number().optional(),
+ cache_read_input_tokens: z.number().optional(),
+ }),
+});
+
+// Streaming types (only what we actually use)
+export const TextDeltaSchema = z.object({
+ type: z.literal("text_delta"),
+ text: z.string(),
+});
+
+export const ContentBlockDeltaEventSchema = z.object({
+ type: z.literal("content_block_delta"),
+ index: z.number(),
+ delta: TextDeltaSchema,
+});
+
+// Inferred types
+export type TextBlock = z.infer<typeof TextBlockSchema>;
+export type ImageBlock = z.infer<typeof ImageBlockSchema>;
+export type ToolUseBlock = z.infer<typeof ToolUseBlockSchema>;
+export type ToolResultBlock = z.infer<typeof ToolResultBlockSchema>;
+export type ThinkingBlock = z.infer<typeof ThinkingBlockSchema>;
+export type RedactedThinkingBlock = z.infer<typeof RedactedThinkingBlockSchema>;
+export type ContentBlock = z.infer<typeof ContentBlockSchema>;
+export type AnthropicMessage = z.infer<typeof AnthropicMessageSchema>;
+export type Tool = z.infer<typeof ToolSchema>;
+export type AnthropicRequest = z.infer<typeof AnthropicRequestSchema>;
+export type AnthropicResponse = z.infer<typeof AnthropicResponseSchema>;
+export type TextDelta = z.infer<typeof TextDeltaSchema>;
+export type ContentBlockDeltaEvent = z.infer<typeof ContentBlockDeltaEventSchema>;
--- /dev/null
+import { describe, expect, it } from "bun:test";
+import { ProviderError } from "./errors";
+
+describe("ProviderError", () => {
+ describe("errorMessage getter", () => {
+ it("extracts message from OpenAI error format", () => {
+ const body = JSON.stringify({
+ error: {
+ message: "Invalid API key provided",
+ type: "invalid_request_error",
+ },
+ });
+ const error = new ProviderError(401, "Unauthorized", body);
+
+ expect(error.errorMessage).toBe("Invalid API key provided");
+ });
+
+ it("extracts message from Anthropic error format", () => {
+ const body = JSON.stringify({
+ type: "error",
+ error: {
+ type: "invalid_request_error",
+ message: "max_tokens must be greater than thinking.budget_tokens",
+ },
+ });
+ const error = new ProviderError(400, "Bad Request", body);
+
+ expect(error.errorMessage).toBe("max_tokens must be greater than thinking.budget_tokens");
+ });
+
+ it("returns truncated body for unknown JSON format", () => {
+ const body = JSON.stringify({ unknown: "format", data: "value" });
+ const error = new ProviderError(500, "Internal Server Error", body);
+
+ expect(error.errorMessage).toBe(body);
+ });
+
+ it("returns truncated body for non-JSON response", () => {
+ const body = "Internal server error occurred";
+ const error = new ProviderError(500, "Internal Server Error", body);
+
+ expect(error.errorMessage).toBe(body);
+ });
+
+ it("truncates long error bodies", () => {
+ const longBody = "x".repeat(600);
+ const error = new ProviderError(500, "Internal Server Error", longBody);
+
+ expect(error.errorMessage).toHaveLength(503); // 500 + "..."
+ expect(error.errorMessage).toEndWith("...");
+ });
+ });
+});
*/
/**
- * Error from upstream provider (OpenAI, etc.)
+ * Error from upstream provider (OpenAI, Anthropic, etc.)
*/
export class ProviderError extends Error {
constructor(
super(`Provider error: ${status} ${statusText}`);
this.name = "ProviderError";
}
+
+ /**
+ * Extracts the error message from the response body.
+ * Parses JSON and looks for OpenAI/Anthropic error format.
+ * Returns the message without status (since status is stored separately).
+ */
+ get errorMessage(): string {
+ try {
+ const parsed = JSON.parse(this.body);
+
+ // OpenAI: { error: { message: "..." } }
+ // Anthropic: { type: "error", error: { message: "..." } }
+ if (parsed.error?.message) {
+ return parsed.error.message;
+ }
+
+ // Unknown format - return truncated body
+ return this.body.length > 500 ? `${this.body.slice(0, 500)}...` : this.body;
+ } catch {
+ // Not JSON - return truncated body
+ return this.body.length > 500 ? `${this.body.slice(0, 500)}...` : this.body;
+ }
+ }
}
import type { LocalProviderConfig } from "../config";
import { HEALTH_CHECK_TIMEOUT_MS, REQUEST_TIMEOUT_MS } from "../constants/timeouts";
+import type { AnthropicResult } from "./anthropic/client";
+import type { AnthropicRequest, AnthropicResponse } from "./anthropic/types";
import { ProviderError, type ProviderResult } from "./openai/client";
import type { OpenAIRequest } from "./openai/types";
return { response: await response.json(), isStreaming: false, model: config.model };
}
+/**
+ * Call local LLM with Anthropic Messages API format
+ * Used in route mode for PII-containing Anthropic requests
+ * Ollama supports Anthropic API at /v1/messages
+ */
+export async function callLocalAnthropic(
+ request: AnthropicRequest,
+ config: LocalProviderConfig,
+): Promise<AnthropicResult> {
+ const baseUrl = config.base_url.replace(/\/$/, "");
+ // Ollama's Anthropic-compatible endpoint
+ const endpoint = `${baseUrl}/v1/messages`;
+
+ const headers: Record<string, string> = {
+ "Content-Type": "application/json",
+ };
+ if (config.api_key) {
+ headers.Authorization = `Bearer ${config.api_key}`;
+ }
+
+ const isStreaming = request.stream ?? false;
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ ...request, model: config.model, stream: isStreaming }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new ProviderError(response.status, response.statusText, await response.text());
+ }
+
+ if (isStreaming) {
+ if (!response.body) {
+ throw new Error("No response body for streaming request");
+ }
+ return { response: response.body, isStreaming: true, model: config.model };
+ }
+
+ return {
+ response: (await response.json()) as AnthropicResponse,
+ isStreaming: false,
+ model: config.model,
+ };
+}
+
/**
* Check if local provider is reachable
*/
*/
import type { OpenAIProviderConfig } from "../../config";
-import { HEALTH_CHECK_TIMEOUT_MS, REQUEST_TIMEOUT_MS } from "../../constants/timeouts";
+import { REQUEST_TIMEOUT_MS } from "../../constants/timeouts";
import { ProviderError } from "../errors";
import type { OpenAIRequest, OpenAIResponse } from "./types";
return { response: await response.json(), isStreaming: false, model };
}
-/**
- * Check if OpenAI API is reachable
- */
-export async function checkOpenAIHealth(config: OpenAIProviderConfig): Promise<boolean> {
- try {
- const baseUrl = config.base_url.replace(/\/$/, "");
- // Use models endpoint - returns 401 if no auth, 200 if OK
- const response = await fetch(`${baseUrl}/models`, {
- method: "GET",
- signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
- });
-
- // 401 means API is up but no auth - that's OK for health check
- // 200 means API is up with valid auth
- return response.status === 401 || response.status === 200;
- } catch {
- return false;
- }
-}
-
/**
* Get OpenAI provider info for /info endpoint
*/
--- /dev/null
+import { describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { anthropicRoutes } from "./anthropic";
+
+const app = new Hono();
+app.route("/anthropic", anthropicRoutes);
+
+describe("POST /anthropic/v1/messages", () => {
+ test("returns 400 for missing messages", async () => {
+ const res = await app.request("/anthropic/v1/messages", {
+ method: "POST",
+ body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 100 }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: { type: string } };
+ expect(body.error.type).toBe("invalid_request_error");
+ });
+
+ test("returns 400 for empty messages array", async () => {
+ const res = await app.request("/anthropic/v1/messages", {
+ method: "POST",
+ body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 100, messages: [] }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ test("returns 400 for invalid role", async () => {
+ const res = await app.request("/anthropic/v1/messages", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "claude-3-haiku-20240307",
+ max_tokens: 100,
+ messages: [{ role: "invalid", content: "test" }],
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ test("returns 400 for missing model", async () => {
+ const res = await app.request("/anthropic/v1/messages", {
+ method: "POST",
+ body: JSON.stringify({
+ max_tokens: 100,
+ messages: [{ role: "user", content: "Hello" }],
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ test("returns 400 for missing max_tokens", async () => {
+ const res = await app.request("/anthropic/v1/messages", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "claude-3-haiku-20240307",
+ messages: [{ role: "user", content: "Hello" }],
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(res.status).toBe(400);
+ });
+});
--- /dev/null
+/**
+ * Anthropic-compatible messages route
+ *
+ * Flow:
+ * 1. Validate request
+ * 2. Process secrets (detect, maybe block, mask, or route_local)
+ * 3. Detect PII
+ * 4. Route mode: if PII found, send to local provider
+ * 5. Mask mode: mask PII if found, send to Anthropic, unmask response
+ */
+
+import { zValidator } from "@hono/zod-validator";
+import type { Context } from "hono";
+import { Hono } from "hono";
+import { getConfig } from "../config";
+import type { PlaceholderContext } from "../masking/context";
+import {
+ anthropicExtractor,
+ extractAnthropicTextContent,
+ extractSystemText,
+} from "../masking/extractors/anthropic";
+import { unmaskResponse as unmaskPIIResponse } from "../pii/mask";
+import { callAnthropic } from "../providers/anthropic/client";
+import { createAnthropicUnmaskingStream } from "../providers/anthropic/stream-transformer";
+import {
+ type AnthropicRequest,
+ AnthropicRequestSchema,
+ type AnthropicResponse,
+} from "../providers/anthropic/types";
+import { callLocalAnthropic } from "../providers/local";
+import { unmaskSecretsResponse } from "../secrets/mask";
+import { logRequest } from "../services/logger";
+import { detectPII, maskPII, type PIIDetectResult } from "../services/pii";
+import { processSecretsRequest, type SecretsProcessResult } from "../services/secrets";
+import {
+ createLogData,
+ errorFormats,
+ handleProviderError,
+ setBlockedHeaders,
+ setResponseHeaders,
+ toPIIHeaderData,
+ toPIILogData,
+ toSecretsHeaderData,
+ toSecretsLogData,
+} from "./utils";
+
+export const anthropicRoutes = new Hono();
+
+/**
+ * POST /v1/messages - Anthropic-compatible messages endpoint
+ */
+anthropicRoutes.post(
+ "/v1/messages",
+ zValidator("json", AnthropicRequestSchema, (result, c) => {
+ if (!result.success) {
+ return c.json(
+ errorFormats.anthropic.error(
+ `Invalid request body: ${result.error.message}`,
+ "invalid_request_error",
+ ),
+ 400,
+ );
+ }
+ }),
+ async (c) => {
+ const startTime = Date.now();
+ let request = c.req.valid("json") as AnthropicRequest;
+ const config = getConfig();
+
+ // Route mode requires local provider
+ if (config.mode === "route" && !config.local) {
+ return respondError(c, "Route mode requires local provider configuration.", 400);
+ }
+
+ // route_local secrets action requires local provider
+ if (
+ config.secrets_detection.enabled &&
+ config.secrets_detection.action === "route_local" &&
+ !config.local
+ ) {
+ return respondError(
+ c,
+ "secrets_detection.action 'route_local' requires local provider.",
+ 400,
+ );
+ }
+
+ // Check if Anthropic provider is configured (required for mask mode, optional for route mode)
+ if (config.mode === "mask" && !config.providers.anthropic) {
+ return respondError(
+ c,
+ "Anthropic provider not configured. Add providers.anthropic to config.yaml.",
+ 400,
+ );
+ }
+
+ // Step 1: Process secrets
+ const secretsResult = processSecretsRequest(
+ request,
+ config.secrets_detection,
+ anthropicExtractor,
+ );
+
+ if (secretsResult.blocked) {
+ return respondBlocked(c, request, secretsResult, startTime);
+ }
+
+ // Apply secrets masking to request
+ if (secretsResult.masked) {
+ request = secretsResult.request;
+ }
+
+ // Step 2: Detect PII (skip if disabled)
+ let piiResult: PIIDetectResult;
+ if (!config.pii_detection.enabled) {
+ piiResult = {
+ detection: {
+ hasPII: false,
+ spanEntities: [],
+ allEntities: [],
+ scanTimeMs: 0,
+ language: "en",
+ languageFallback: false,
+ },
+ hasPII: false,
+ };
+ } else {
+ try {
+ piiResult = await detectPII(request, anthropicExtractor);
+ } catch (error) {
+ console.error("PII detection error:", error);
+ return respondDetectionError(c, request, secretsResult, startTime);
+ }
+ }
+
+ // Step 3: Route mode - send to local if PII or secrets detected
+ const shouldRouteToLocal =
+ config.mode === "route" &&
+ (piiResult.hasPII ||
+ (secretsResult.detection?.detected && config.secrets_detection.action === "route_local"));
+
+ if (shouldRouteToLocal) {
+ return sendToLocal(c, request, {
+ request,
+ startTime,
+ piiResult,
+ secretsResult,
+ });
+ }
+
+ // Step 4: Mask mode - mask PII if found, send to Anthropic
+ let piiMaskingContext: PlaceholderContext | undefined;
+ let maskedContent: string | undefined;
+
+ if (piiResult.hasPII) {
+ const masked = maskPII(request, piiResult.detection, anthropicExtractor);
+ request = masked.request;
+ piiMaskingContext = masked.maskingContext;
+ maskedContent = formatRequestForLog(request);
+ } else if (secretsResult.masked) {
+ maskedContent = formatRequestForLog(request);
+ }
+
+ // Step 5: Send to Anthropic
+ return sendToAnthropic(c, request, {
+ startTime,
+ piiResult,
+ piiMaskingContext,
+ secretsResult,
+ maskedContent,
+ });
+ },
+);
+
+/**
+ * Proxy all other requests to Anthropic
+ *
+ * Transparent header forwarding - all auth headers from client are passed through.
+ */
+anthropicRoutes.all("/*", async (c) => {
+ const config = getConfig();
+
+ if (!config.providers.anthropic) {
+ return respondError(
+ c,
+ "Anthropic provider not configured. Add providers.anthropic to config.yaml.",
+ 400,
+ );
+ }
+
+ const { proxy } = await import("hono/proxy");
+ const baseUrl = config.providers.anthropic.base_url || "https://api.anthropic.com";
+ const path = c.req.path.replace(/^\/anthropic/, "");
+ const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : "";
+
+ return proxy(`${baseUrl}${path}${query}`, {
+ ...c.req,
+ headers: {
+ ...c.req.header(),
+ "X-Forwarded-Host": c.req.header("host"),
+ host: undefined,
+ },
+ });
+});
+
+// --- Types ---
+
+interface SendOptions {
+ startTime: number;
+ piiResult: PIIDetectResult;
+ piiMaskingContext?: PlaceholderContext;
+ secretsResult: SecretsProcessResult<AnthropicRequest>;
+ maskedContent?: string;
+}
+
+interface LocalOptions {
+ request: AnthropicRequest;
+ startTime: number;
+ piiResult: PIIDetectResult;
+ secretsResult: SecretsProcessResult<AnthropicRequest>;
+}
+
+// --- Helpers ---
+
+function formatRequestForLog(request: AnthropicRequest): string {
+ const parts: string[] = [];
+
+ if (request.system) {
+ const systemText = extractSystemText(request.system);
+ if (systemText) parts.push(`[system] ${systemText}`);
+ }
+
+ for (const msg of request.messages) {
+ const text = extractAnthropicTextContent(msg.content);
+ const isMultimodal = Array.isArray(msg.content);
+ parts.push(`[${msg.role}${isMultimodal ? " multimodal" : ""}] ${text}`);
+ }
+
+ return parts.join("\n");
+}
+
+// --- Response handlers ---
+
+function respondError(c: Context, message: string, status: number) {
+ return c.json(
+ errorFormats.anthropic.error(message, status >= 500 ? "server_error" : "invalid_request_error"),
+ status as 400 | 500 | 502 | 503,
+ );
+}
+
+function respondBlocked(
+ c: Context,
+ request: AnthropicRequest,
+ secretsResult: SecretsProcessResult<AnthropicRequest>,
+ startTime: number,
+) {
+ const secretTypes = secretsResult.blockedTypes ?? [];
+
+ setBlockedHeaders(c, secretTypes);
+
+ logRequest(
+ createLogData({
+ provider: "anthropic",
+ model: request.model,
+ startTime,
+ secrets: { detected: true, matches: secretTypes.map((t) => ({ type: t })), masked: false },
+ statusCode: 400,
+ errorMessage: `Request blocked: detected secret material (${secretTypes.join(",")})`,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+
+ return c.json(
+ errorFormats.anthropic.error(
+ `Request blocked: detected secret material (${secretTypes.join(",")}). Remove secrets and retry.`,
+ "invalid_request_error",
+ ),
+ 400,
+ );
+}
+
+function respondDetectionError(
+ c: Context,
+ request: AnthropicRequest,
+ secretsResult: SecretsProcessResult<AnthropicRequest>,
+ startTime: number,
+) {
+ logRequest(
+ createLogData({
+ provider: "anthropic",
+ model: request.model,
+ startTime,
+ secrets: toSecretsLogData(secretsResult),
+ statusCode: 503,
+ errorMessage: "PII detection service unavailable",
+ }),
+ c.req.header("User-Agent") || null,
+ );
+
+ return respondError(c, "PII detection service unavailable", 503);
+}
+
+// --- Provider handlers ---
+
+async function sendToLocal(c: Context, originalRequest: AnthropicRequest, opts: LocalOptions) {
+ const config = getConfig();
+ const { request, piiResult, secretsResult, startTime } = opts;
+
+ if (!config.local) {
+ throw new Error("Local provider not configured");
+ }
+
+ const maskedContent =
+ piiResult.hasPII || secretsResult.masked ? formatRequestForLog(request) : undefined;
+
+ setResponseHeaders(
+ c,
+ config.mode,
+ "local",
+ toPIIHeaderData(piiResult),
+ toSecretsHeaderData(secretsResult),
+ );
+
+ try {
+ const result = await callLocalAnthropic(request, config.local);
+
+ logRequest(
+ createLogData({
+ provider: "local",
+ model: result.model || originalRequest.model,
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+
+ if (result.isStreaming) {
+ c.header("Content-Type", "text/event-stream");
+ c.header("Cache-Control", "no-cache");
+ c.header("Connection", "keep-alive");
+ return c.body(result.response as ReadableStream);
+ }
+
+ return c.json(result.response);
+ } catch (error) {
+ return handleProviderError(
+ c,
+ error,
+ {
+ provider: "local",
+ model: originalRequest.model,
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ userAgent: c.req.header("User-Agent") || null,
+ },
+ (msg) => errorFormats.anthropic.error(msg, "server_error"),
+ );
+ }
+}
+
+async function sendToAnthropic(c: Context, request: AnthropicRequest, opts: SendOptions) {
+ const config = getConfig();
+ const { startTime, piiResult, piiMaskingContext, secretsResult, maskedContent } = opts;
+
+ setResponseHeaders(
+ c,
+ config.mode,
+ "anthropic",
+ toPIIHeaderData(piiResult),
+ toSecretsHeaderData(secretsResult),
+ );
+
+ const clientHeaders = {
+ apiKey: c.req.header("x-api-key"),
+ authorization: c.req.header("Authorization"),
+ beta: c.req.header("anthropic-beta"),
+ };
+
+ try {
+ const result = await callAnthropic(request, config.providers.anthropic!, clientHeaders);
+
+ logRequest(
+ createLogData({
+ provider: "anthropic",
+ model: result.model || request.model,
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+
+ if (result.isStreaming) {
+ return respondStreaming(c, result.response, piiMaskingContext, secretsResult.maskingContext);
+ }
+
+ return respondJson(c, result.response, piiMaskingContext, secretsResult.maskingContext);
+ } catch (error) {
+ return handleProviderError(
+ c,
+ error,
+ {
+ provider: "anthropic",
+ model: request.model,
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ userAgent: c.req.header("User-Agent") || null,
+ },
+ (msg) => errorFormats.anthropic.error(msg, "server_error"),
+ );
+ }
+}
+
+// --- Response formatters ---
+
+function respondStreaming(
+ c: Context,
+ stream: ReadableStream<Uint8Array>,
+ piiMaskingContext: PlaceholderContext | undefined,
+ secretsContext: PlaceholderContext | undefined,
+) {
+ const config = getConfig();
+ c.header("Content-Type", "text/event-stream");
+ c.header("Cache-Control", "no-cache");
+ c.header("Connection", "keep-alive");
+
+ if (piiMaskingContext || secretsContext) {
+ const unmaskingStream = createAnthropicUnmaskingStream(
+ stream,
+ piiMaskingContext,
+ config.masking,
+ secretsContext,
+ );
+ return c.body(unmaskingStream);
+ }
+
+ return c.body(stream);
+}
+
+function respondJson(
+ c: Context,
+ response: AnthropicResponse,
+ piiMaskingContext: PlaceholderContext | undefined,
+ secretsContext: PlaceholderContext | undefined,
+) {
+ const config = getConfig();
+ let result = response;
+
+ if (piiMaskingContext) {
+ result = unmaskPIIResponse(result, piiMaskingContext, config.masking, anthropicExtractor);
+ }
+
+ if (secretsContext) {
+ result = unmaskSecretsResponse(result, secretsContext, anthropicExtractor);
+ }
+
+ return c.json(result);
+}
services.presidio = presidioHealth ? "up" : "down";
}
- if (config.mode === "route") {
+ if (config.mode === "route" && config.local) {
services.local_llm = localHealth ? "up" : "down";
}
import pkg from "../../package.json";
import { getConfig } from "../config";
import { getPIIDetector } from "../pii/detect";
+import { getAnthropicInfo } from "../providers/anthropic/client";
import { getLocalInfo } from "../providers/local";
import { getOpenAIInfo } from "../providers/openai/client";
const detector = getPIIDetector();
const languageValidation = detector.getLanguageValidation();
- const providers: Record<string, { base_url: string }> = {
+ const providers = {
openai: {
base_url: getOpenAIInfo(config.providers.openai).baseUrl,
},
+ anthropic: {
+ base_url: getAnthropicInfo(config.providers.anthropic).baseUrl,
+ },
};
const info: Record<string, unknown> = {
import { openaiRoutes } from "./openai";
const app = new Hono();
-app.route("/openai/v1", openaiRoutes);
+app.route("/openai", openaiRoutes);
describe("POST /openai/v1/chat/completions", () => {
test("returns 400 for missing messages", async () => {
* POST /v1/chat/completions
*/
openaiRoutes.post(
- "/chat/completions",
+ "/v1/chat/completions",
zValidator("json", OpenAIRequestSchema, (result, c) => {
if (!result.success) {
return c.json(
};
}
+/**
+ * Error response format for Anthropic
+ */
+export interface AnthropicErrorResponse {
+ type: "error";
+ error: {
+ type: "invalid_request_error" | "server_error";
+ message: string;
+ };
+}
+
/**
* Format adapters for different API schemas
*/
};
},
},
+
+ anthropic: {
+ error(message: string, type: "invalid_request_error" | "server_error"): AnthropicErrorResponse {
+ return {
+ type: "error",
+ error: {
+ type,
+ message,
+ },
+ };
+ },
+ },
};
// ============================================================================
}
export interface CreateLogDataOptions {
- provider: "openai" | "local";
+ provider: "openai" | "anthropic" | "local";
model: string;
startTime: number;
pii?: PIILogData;
// ============================================================================
export interface ProviderErrorContext {
- provider: "openai" | "local";
+ provider: "openai" | "anthropic" | "local";
model: string;
startTime: number;
pii?: PIILogData;
secrets: ctx.secrets,
maskedContent: ctx.maskedContent,
statusCode: error.status,
- errorMessage: error.message,
+ errorMessage: error.errorMessage,
}),
ctx.userAgent,
);
id?: number;
timestamp: string;
mode: "route" | "mask";
- provider: "openai" | "local";
+ provider: "openai" | "anthropic" | "local";
model: string;
pii_detected: boolean;
entities: string;
export interface RequestLogData {
timestamp: string;
mode: "route" | "mask";
- provider: "openai" | "local";
+ provider: "openai" | "anthropic" | "local";
model: string;
piiDetected: boolean;
entities: string[];
--color-info: #2563eb;
--color-info-bg: #dbeafe;
--color-teal: #0d9488;
+ --color-anthropic: #d97706;
/* Code Block Colors */
--color-code-bg: #1c1917;
.bg-success { background: var(--color-success); }
.bg-success\\/10 { background: rgba(22, 163, 74, 0.1); }
.bg-teal { background: var(--color-teal); }
+ .bg-anthropic { background: var(--color-anthropic); }
+ .bg-anthropic\\/10 { background: rgba(217, 119, 6, 0.1); }
.bg-error { background: var(--color-error); }
.bg-error\\/10 { background: rgba(220, 38, 38, 0.1); }
.text-info { color: var(--color-info); }
.text-success { color: var(--color-success); }
.text-teal { color: var(--color-teal); }
+ .text-anthropic { color: var(--color-anthropic); }
.text-error { color: var(--color-error); }
/* Border radius */
'<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + statusBadge + '</td>' +
'<td class="route-only text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
'<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide ' +
- (log.provider === 'openai' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
+ (log.provider === 'openai' ? 'bg-info/10 text-info' : log.provider === 'anthropic' ? 'bg-anthropic/10 text-anthropic' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
'</td>' +
'<td class="font-mono text-[0.7rem] text-text-secondary px-4 py-3 border-b border-border-subtle align-middle">' + log.model + '</td>' +
'<td class="font-mono text-[0.65rem] font-medium px-4 py-3 border-b border-border-subtle align-middle">' + langDisplay + '</td>' +