Add Anthropic API support (#51)
authorStefan Gasser <redacted>
Tue, 20 Jan 2026 22:06:58 +0000 (23:06 +0100)
committerGitHub <redacted>
Tue, 20 Jan 2026 22:06:58 +0000 (23:06 +0100)
* Add Anthropic provider

- Add /anthropic/v1/messages endpoint with full API compatibility
- Support OAuth tokens from Claude Code for subscription users
- Provider-agnostic text extraction for PII/secrets masking
- Support streaming and non-streaming responses
- Remove unused cloud provider health checks (only local services need them)

* Add Anthropic provider documentation

- Update README and introduction to mention Anthropic support
- Add Claude Code and Anthropic SDK to integrations
- Document Anthropic provider config with OAuth support
- Create separate API reference pages for OpenAI and Anthropic
- Update navigation structure

* Improve provider error messages in logs

- Add errorMessage getter to parse OpenAI/Anthropic error formats
- Log parsed error message instead of generic "Provider error"

* Update docs wording for multi-provider support

- Clarify OpenAI and Anthropic APIs with compatible providers
- Note Anthropic endpoint is mask mode only (route mode coming)

* Add route mode support for Anthropic endpoint

- Add callLocalAnthropic function for Ollama's Anthropic API
- Update Anthropic route to support route mode with local provider
- Update docs to reflect both mask and route mode support

* Add Anthropic brand color to dashboard provider badges

* Fix duplicate /v1 prefix in Anthropic proxy wildcard handler

The path variable already contains the full path after stripping the
/anthropic prefix (e.g., /v1/messages or /api/foo). Adding /v1 again
caused double prefixes for v1 paths and incorrect paths for non-v1
endpoints like /api/event_logging/batch.

* Add role field to Anthropic extractor for scan_roles filtering

* Remove OAuth token reading, use transparent header forwarding

- Delete oauth.ts - no longer read tokens from local storage
- Simplify client.ts to forward all auth headers transparently
- Simplify anthropic.ts wildcard handler
- Add Claude Code system prompt to default whitelist
- Whitelist merges user entries with default (not replaces)

* Simplify wildcard proxies to fully transparent passthrough

* Fix wildcard proxy host header forwarding

* Update documentation: simplify intro, remove OAuth docs, add Anthropic to architecture

* Improve documentation: simplify intro, update API links, remove redundant api_key exports

31 files changed:
CLAUDE.md
README.md
docs/api-reference/anthropic.mdx [new file with mode: 0644]
docs/api-reference/openai.mdx [moved from docs/api-reference/chat-completions.mdx with 95% similarity]
docs/concepts/mask-mode.mdx
docs/concepts/route-mode.mdx
docs/configuration/providers.mdx
docs/integrations.mdx
docs/introduction.mdx
docs/mint.json
src/config.ts
src/index.ts
src/masking/extractors/anthropic.test.ts [new file with mode: 0644]
src/masking/extractors/anthropic.ts [new file with mode: 0644]
src/providers/anthropic/client.ts [new file with mode: 0644]
src/providers/anthropic/stream-transformer.test.ts [new file with mode: 0644]
src/providers/anthropic/stream-transformer.ts [new file with mode: 0644]
src/providers/anthropic/types.ts [new file with mode: 0644]
src/providers/errors.test.ts [new file with mode: 0644]
src/providers/errors.ts
src/providers/local.ts
src/providers/openai/client.ts
src/routes/anthropic.test.ts [new file with mode: 0644]
src/routes/anthropic.ts [new file with mode: 0644]
src/routes/health.ts
src/routes/info.ts
src/routes/openai.test.ts
src/routes/openai.ts
src/routes/utils.ts
src/services/logger.ts
src/views/dashboard/page.tsx

index 74d7d60b97ffbdb8880165cf0c8ab83bf1abff32..d932884135a392d1c34c49ad4e177f5a44f4f15f 100644 (file)
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
 # 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
 
@@ -23,6 +23,7 @@ src/
 │   └── 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
@@ -30,10 +31,14 @@ src/
 ├── 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
@@ -41,7 +46,8 @@ src/
 │   ├── 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
@@ -109,6 +115,7 @@ See @docker/presidio/languages.yaml for 24 available languages.
 
 - `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.
index c39fc8e26c058971f0dcd0114bffdc78729b323b..eef14090a2e8cc2fe322b4c8492456634a57cdec 100644 (file)
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
 </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)
 
@@ -50,35 +54,25 @@ Open source (Apache 2.0). Built in public — early feedback shapes the product.
 - **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)
 
@@ -94,9 +88,10 @@ For custom config, persistent logs, or other languages: **[Read the docs →](ht
 
 ## 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
diff --git a/docs/api-reference/anthropic.mdx b/docs/api-reference/anthropic.mdx
new file mode 100644 (file)
index 0000000..13079f7
--- /dev/null
@@ -0,0 +1,134 @@
+---
+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
similarity index 95%
rename from docs/api-reference/chat-completions.mdx
rename to docs/api-reference/openai.mdx
index d98673f7b902612160ae121c43048e10d3c9b0a3..1739962b2e3ad50cb1f4fccf2e79f3829da0c8c4 100644 (file)
@@ -1,9 +1,9 @@
 ---
-title: Chat Completions
+title: OpenAI
 description: POST /openai/v1/chat/completions
 ---
 
-# Chat Completions
+# OpenAI Endpoint
 
 Generate chat completions with automatic PII and secrets protection.
 
@@ -39,7 +39,7 @@ curl http://localhost:3000/openai/v1/chat/completions \
 | `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
 
index 27c2af8b75ef2e28a229fcf5a13f995682987c8f..31f20a16d502537315b90590f82bbdf63ddb951f 100644 (file)
@@ -30,7 +30,7 @@ Mask mode replaces PII with placeholders before sending to your configured provi
 ## 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
 
index f72ff12a98732aeae9f75fa2ec4cec846a90d0e5..e9581f97a9f2c8d82b0f85cd06283c57d6ad323c 100644 (file)
@@ -16,7 +16,7 @@ Route mode sends requests containing PII to a local LLM. Requests without PII go
     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>
@@ -44,9 +44,13 @@ local:
 ```
 
 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
index c8060387073ca6b44b87e904a6f0d98e97dd7e8d..94f764f729510d9ec6e7b36432c46e502b947651 100644 (file)
@@ -7,9 +7,9 @@ description: Configure your LLM providers
 
 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:
@@ -23,7 +23,7 @@ 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:
 
@@ -60,6 +60,22 @@ providers:
     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.
@@ -117,11 +133,15 @@ local:
 
 ## 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
 ```
index f9d67d0374372810193af978e26afe0c0ef46f78..0d885fbc7fe2c0ceeb84adca15e424044e650a4f 100644 (file)
@@ -5,42 +5,52 @@ description: Use PasteGuard with IDEs, chat interfaces, and SDKs
 
 # 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
@@ -48,16 +58,38 @@ cache: true
 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
 
@@ -67,8 +99,7 @@ endpoints:
 from openai import OpenAI
 
 client = OpenAI(
-    base_url="http://localhost:3000/openai/v1",
-    api_key="your-key"
+    base_url="http://localhost:3000/openai/v1"
 )
 ```
 
@@ -76,8 +107,7 @@ client = OpenAI(
 import OpenAI from 'openai';
 
 const client = new OpenAI({
-  baseURL: 'http://localhost:3000/openai/v1',
-  apiKey: 'your-key'
+  baseURL: 'http://localhost:3000/openai/v1'
 });
 ```
 
@@ -89,8 +119,7 @@ const client = new OpenAI({
 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"
 )
 ```
 
@@ -101,19 +130,21 @@ from llama_index.llms.openai_like import OpenAILike
 
 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
index bb0538b295502aa15cae61653dff4925fa7d9365..1e4e843ab450af0ec209f2fcb5025452d3e6de23 100644 (file)
@@ -3,15 +3,13 @@ title: Introduction
 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:
 
@@ -39,40 +37,16 @@ Open source (Apache 2.0). Built in public — early feedback shapes the product.
   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
index a8801bd3ad406511c8dd76202e2b26f2865c8414..b34545e30ff260cede62e9cc3eb51bb4e0fe08cc 100644 (file)
@@ -48,7 +48,8 @@
     {
       "group": "API Reference",
       "pages": [
-        "api-reference/chat-completions",
+        "api-reference/openai",
+        "api-reference/anthropic",
         "api-reference/status",
         "api-reference/dashboard-api"
       ]
index f26ac32a697faba12a48bb51884ec703a1ff38b6..324b5c084dd42e175123bcd7bd6e883f68183ce3 100644 (file)
@@ -19,10 +19,21 @@ const OpenAIProviderSchema = z.object({
   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);
@@ -110,6 +121,7 @@ const ConfigSchema = z
     // Providers
     providers: z.object({
       openai: OpenAIProviderSchema.default({}),
+      anthropic: AnthropicProviderSchema.default({}),
     }),
     // Local provider - only for route mode
     local: LocalProviderSchema.optional(),
@@ -147,6 +159,7 @@ const ConfigSchema = z
 
 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>;
index 3eb1c97e7ff5f0304eea7185c85b94195d3e4659..0e852194375c7d2e15efc80535b7c6ce45bfaec2 100644 (file)
@@ -5,6 +5,7 @@ import { HTTPException } from "hono/http-exception";
 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";
@@ -42,7 +43,8 @@ app.get("/favicon.svg", (c) => {
 
 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);
@@ -178,6 +180,7 @@ Provider:
 
 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
diff --git a/src/masking/extractors/anthropic.test.ts b/src/masking/extractors/anthropic.test.ts
new file mode 100644 (file)
index 0000000..bf16c69
--- /dev/null
@@ -0,0 +1,740 @@
+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");
+    });
+  });
+});
diff --git a/src/masking/extractors/anthropic.ts b/src/masking/extractors/anthropic.ts
new file mode 100644 (file)
index 0000000..a472bc1
--- /dev/null
@@ -0,0 +1,300 @@
+/**
+ * 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;
+      }),
+    };
+  },
+};
diff --git a/src/providers/anthropic/client.ts b/src/providers/anthropic/client.ts
new file mode 100644 (file)
index 0000000..f73b69e
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * 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,
+  };
+}
diff --git a/src/providers/anthropic/stream-transformer.test.ts b/src/providers/anthropic/stream-transformer.test.ts
new file mode 100644 (file)
index 0000000..84d430d
--- /dev/null
@@ -0,0 +1,352 @@
+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?");
+  });
+});
diff --git a/src/providers/anthropic/stream-transformer.ts b/src/providers/anthropic/stream-transformer.ts
new file mode 100644 (file)
index 0000000..87c972a
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * 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();
+      }
+    },
+  });
+}
diff --git a/src/providers/anthropic/types.ts b/src/providers/anthropic/types.ts
new file mode 100644 (file)
index 0000000..08956d7
--- /dev/null
@@ -0,0 +1,139 @@
+/**
+ * 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>;
diff --git a/src/providers/errors.test.ts b/src/providers/errors.test.ts
new file mode 100644 (file)
index 0000000..b5fbdc5
--- /dev/null
@@ -0,0 +1,53 @@
+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("...");
+    });
+  });
+});
index d9a5a1e612c89fbda5086774d54ffbc14e91508c..59510dbcebdf4a05ed813cdfdad0225efced5dab 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 /**
- * Error from upstream provider (OpenAI, etc.)
+ * Error from upstream provider (OpenAI, Anthropic, etc.)
  */
 export class ProviderError extends Error {
   constructor(
@@ -14,4 +14,27 @@ export class ProviderError extends Error {
     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;
+    }
+  }
 }
index d00fd263fecb12d4aa3ab1ea001b06eb7c6f1ce8..6670655596b1eed364cf98b5869e8aa33b502ea7 100644 (file)
@@ -5,6 +5,8 @@
 
 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";
 
@@ -47,6 +49,53 @@ export async function callLocal(
   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
  */
index a5bf0506e1bb8b778c0ff7585330fd7cff92f729..9768abc9d308240428288971a0a9dc79a97bb428 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 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";
 
@@ -87,26 +87,6 @@ export async function callOpenAI(
   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
  */
diff --git a/src/routes/anthropic.test.ts b/src/routes/anthropic.test.ts
new file mode 100644 (file)
index 0000000..cc93dcf
--- /dev/null
@@ -0,0 +1,70 @@
+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);
+  });
+});
diff --git a/src/routes/anthropic.ts b/src/routes/anthropic.ts
new file mode 100644 (file)
index 0000000..0153e8c
--- /dev/null
@@ -0,0 +1,465 @@
+/**
+ * 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);
+}
index a1696eef150272c970c953d332c1f382ccd58c2c..e8051ab3a6836d85894ba915684093294c35b55e 100644 (file)
@@ -23,7 +23,7 @@ healthRoutes.get("/health", async (c) => {
     services.presidio = presidioHealth ? "up" : "down";
   }
 
-  if (config.mode === "route") {
+  if (config.mode === "route" && config.local) {
     services.local_llm = localHealth ? "up" : "down";
   }
 
index 8d80c01f5bdb56b98ed39f13f7a8482d121da17c..eb01fcb04b2213cb7450b425d22c4d04e5eff761 100644 (file)
@@ -2,6 +2,7 @@ import { Hono } from "hono";
 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";
 
@@ -12,10 +13,13 @@ infoRoutes.get("/info", (c) => {
   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> = {
index aaf80ac3c39421fb6cadf56c62f8031e1d20f2a4..d7fda9e89a53fda779d03c120f1f8343b06dfa71 100644 (file)
@@ -3,7 +3,7 @@ import { Hono } from "hono";
 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 () => {
index 60fad19b4aafe338d275c2f57ac6a3ba87402769..d4c347c7186e82e7903b637b2f11d3bde2b71815 100644 (file)
@@ -51,7 +51,7 @@ export const openaiRoutes = new Hono();
  * POST /v1/chat/completions
  */
 openaiRoutes.post(
-  "/chat/completions",
+  "/v1/chat/completions",
   zValidator("json", OpenAIRequestSchema, (result, c) => {
     if (!result.success) {
       return c.json(
index 4f1e891732c6510fcbaf0610fe78bfbe4a8555aa..54b0cc534fb28b05d72733de11ccaa95625f55ef 100644 (file)
@@ -29,6 +29,17 @@ export interface OpenAIErrorResponse {
   };
 }
 
+/**
+ * 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
  */
@@ -49,6 +60,18 @@ export const errorFormats = {
       };
     },
   },
+
+  anthropic: {
+    error(message: string, type: "invalid_request_error" | "server_error"): AnthropicErrorResponse {
+      return {
+        type: "error",
+        error: {
+          type,
+          message,
+        },
+      };
+    },
+  },
 };
 
 // ============================================================================
@@ -184,7 +207,7 @@ export function toSecretsHeaderData<T>(
 }
 
 export interface CreateLogDataOptions {
-  provider: "openai" | "local";
+  provider: "openai" | "anthropic" | "local";
   model: string;
   startTime: number;
   pii?: PIILogData;
@@ -227,7 +250,7 @@ export function createLogData(options: CreateLogDataOptions): RequestLogData {
 // ============================================================================
 
 export interface ProviderErrorContext {
-  provider: "openai" | "local";
+  provider: "openai" | "anthropic" | "local";
   model: string;
   startTime: number;
   pii?: PIILogData;
@@ -261,7 +284,7 @@ export function handleProviderError(
         secrets: ctx.secrets,
         maskedContent: ctx.maskedContent,
         statusCode: error.status,
-        errorMessage: error.message,
+        errorMessage: error.errorMessage,
       }),
       ctx.userAgent,
     );
index 3b335bd62cfe53c4aa7475f272ca56d9ac211f88..93a0debf9314fad47a6a82fdd30a7945458a6180 100644 (file)
@@ -6,7 +6,7 @@ export interface RequestLog {
   id?: number;
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "local";
+  provider: "openai" | "anthropic" | "local";
   model: string;
   pii_detected: boolean;
   entities: string;
@@ -282,7 +282,7 @@ export function getLogger(): Logger {
 export interface RequestLogData {
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "local";
+  provider: "openai" | "anthropic" | "local";
   model: string;
   piiDetected: boolean;
   entities: string[];
index a6dec0621498f38e4304dcb37c2ba1553ef6fcfc..1cb16bcdad558f6fc40c7b36236525ae0e818e5c 100644 (file)
@@ -42,6 +42,7 @@ const DashboardPage: FC = () => {
                                                                --color-info: #2563eb;
                                                                --color-info-bg: #dbeafe;
                                                                --color-teal: #0d9488;
+                                                               --color-anthropic: #d97706;
 
                                                                /* Code Block Colors */
                                                                --color-code-bg: #1c1917;
@@ -95,6 +96,8 @@ const DashboardPage: FC = () => {
                                                        .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); }
 
@@ -113,6 +116,7 @@ const DashboardPage: FC = () => {
                                                        .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 */
@@ -592,7 +596,7 @@ async function fetchLogs() {
           '<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>' +
git clone https://git.99rst.org/PROJECT