From: Stefan Gasser Date: Tue, 20 Jan 2026 22:06:58 +0000 (+0100) Subject: Add Anthropic API support (#51) X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=f083f542336ea42f9eaa9fbc2d87e0719dfe9400;p=sgasser-llm-shield.git Add Anthropic API support (#51) * 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 --- diff --git a/CLAUDE.md b/CLAUDE.md index 74d7d60..d932884 100644 --- 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. diff --git a/README.md b/README.md index c39fc8e..eef1409 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- 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.

@@ -23,16 +23,20 @@ ## 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 index 0000000..13079f7 --- /dev/null +++ b/docs/api-reference/anthropic.mdx @@ -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 +``` + + +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). + + +## 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: + + + +```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); + } +} +``` + + + +## 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 diff --git a/docs/api-reference/chat-completions.mdx b/docs/api-reference/openai.mdx similarity index 95% rename from docs/api-reference/chat-completions.mdx rename to docs/api-reference/openai.mdx index d98673f..1739962 100644 --- a/docs/api-reference/chat-completions.mdx +++ b/docs/api-reference/openai.mdx @@ -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 diff --git a/docs/concepts/mask-mode.mdx b/docs/concepts/mask-mode.mdx index 27c2af8..31f20a1 100644 --- a/docs/concepts/mask-mode.mdx +++ b/docs/concepts/mask-mode.mdx @@ -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 diff --git a/docs/concepts/route-mode.mdx b/docs/concepts/route-mode.mdx index f72ff12..e9581f9 100644 --- a/docs/concepts/route-mode.mdx +++ b/docs/concepts/route-mode.mdx @@ -16,7 +16,7 @@ Route mode sends requests containing PII to a local LLM. Requests without PII go PII stays on your network. - Routed to **Configured Provider** (OpenAI, Azure, self-hosted, etc.) + Routed to **Configured Provider** (OpenAI, Anthropic, Azure, etc.) Full provider performance. @@ -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 + +For Anthropic requests, the local provider must support the Anthropic Messages API (e.g., Ollama with Anthropic API compatibility). + + ## Local Provider Setup ### Ollama diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index c806038..94f764f 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -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 ``` diff --git a/docs/integrations.mdx b/docs/integrations.mdx index f9d67d0..0d885fb 100644 --- a/docs/integrations.mdx +++ b/docs/integrations.mdx @@ -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. +In Docker Compose, use the service name instead of `localhost` (e.g., `http://pasteguard:3000/openai/v1`). ### 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 + + + +```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' +}); +``` + + ### 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 diff --git a/docs/introduction.mdx b/docs/introduction.mdx index bb0538b..1e4e843 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -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 -## Quick Example - - - - ``` - Write a follow-up email to Dr. Sarah Chen (sarah.chen@hospital.org) - ``` - - - Detected: `Dr. Sarah Chen` → `[[PERSON_1]]`, `sarah.chen@hospital.org` → `[[EMAIL_ADDRESS_1]]` - - - ``` - Write a follow-up email to [[PERSON_1]] ([[EMAIL_ADDRESS_1]]) - ``` - - - ``` - Dear Dr. Sarah Chen, Following up on our discussion... - ``` - - - -The LLM never sees the real data. PII is masked before sending and restored in the response. - ## 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 diff --git a/docs/mint.json b/docs/mint.json index a8801bd..b34545e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -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" ] diff --git a/src/config.ts b/src/config.ts index f26ac32..324b5c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; export type OpenAIProviderConfig = z.infer; +export type AnthropicProviderConfig = z.infer; export type LocalProviderConfig = z.infer; export type MaskingConfig = z.infer; export type SecretsDetectionConfig = z.infer; diff --git a/src/index.ts b/src/index.ts index 3eb1c97..0e85219 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 index 0000000..bf16c69 --- /dev/null +++ b/src/masking/extractors/anthropic.test.ts @@ -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 index 0000000..a472bc1 --- /dev/null +++ b/src/masking/extractors/anthropic.ts @@ -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 = { + 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 index 0000000..f73b69e --- /dev/null +++ b/src/providers/anthropic/client.ts @@ -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; + 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 { + const isStreaming = request.stream ?? false; + const baseUrl = (config.base_url || DEFAULT_ANTHROPIC_URL).replace(/\/$/, ""); + + const headers: Record = { + "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 index 0000000..84d430d --- /dev/null +++ b/src/providers/anthropic/stream-transformer.test.ts @@ -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 { + 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): Promise { + 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 index 0000000..87c972a --- /dev/null +++ b/src/providers/anthropic/stream-transformer.ts @@ -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, + piiContext: PlaceholderContext | undefined, + config: MaskingConfig, + secretsContext?: PlaceholderContext, +): ReadableStream { + 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 index 0000000..08956d7 --- /dev/null +++ b/src/providers/anthropic/types.ts @@ -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; +export type ImageBlock = z.infer; +export type ToolUseBlock = z.infer; +export type ToolResultBlock = z.infer; +export type ThinkingBlock = z.infer; +export type RedactedThinkingBlock = z.infer; +export type ContentBlock = z.infer; +export type AnthropicMessage = z.infer; +export type Tool = z.infer; +export type AnthropicRequest = z.infer; +export type AnthropicResponse = z.infer; +export type TextDelta = z.infer; +export type ContentBlockDeltaEvent = z.infer; diff --git a/src/providers/errors.test.ts b/src/providers/errors.test.ts new file mode 100644 index 0000000..b5fbdc5 --- /dev/null +++ b/src/providers/errors.test.ts @@ -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("..."); + }); + }); +}); diff --git a/src/providers/errors.ts b/src/providers/errors.ts index d9a5a1e..59510db 100644 --- a/src/providers/errors.ts +++ b/src/providers/errors.ts @@ -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; + } + } } diff --git a/src/providers/local.ts b/src/providers/local.ts index d00fd26..6670655 100644 --- a/src/providers/local.ts +++ b/src/providers/local.ts @@ -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 { + const baseUrl = config.base_url.replace(/\/$/, ""); + // Ollama's Anthropic-compatible endpoint + const endpoint = `${baseUrl}/v1/messages`; + + const headers: Record = { + "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 */ diff --git a/src/providers/openai/client.ts b/src/providers/openai/client.ts index a5bf050..9768abc 100644 --- a/src/providers/openai/client.ts +++ b/src/providers/openai/client.ts @@ -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 { - 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 index 0000000..cc93dcf --- /dev/null +++ b/src/routes/anthropic.test.ts @@ -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 index 0000000..0153e8c --- /dev/null +++ b/src/routes/anthropic.ts @@ -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; + maskedContent?: string; +} + +interface LocalOptions { + request: AnthropicRequest; + startTime: number; + piiResult: PIIDetectResult; + secretsResult: SecretsProcessResult; +} + +// --- 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, + 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, + 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, + 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); +} diff --git a/src/routes/health.ts b/src/routes/health.ts index a1696ee..e8051ab 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -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"; } diff --git a/src/routes/info.ts b/src/routes/info.ts index 8d80c01..eb01fcb 100644 --- a/src/routes/info.ts +++ b/src/routes/info.ts @@ -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 = { + const providers = { openai: { base_url: getOpenAIInfo(config.providers.openai).baseUrl, }, + anthropic: { + base_url: getAnthropicInfo(config.providers.anthropic).baseUrl, + }, }; const info: Record = { diff --git a/src/routes/openai.test.ts b/src/routes/openai.test.ts index aaf80ac..d7fda9e 100644 --- a/src/routes/openai.test.ts +++ b/src/routes/openai.test.ts @@ -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 () => { diff --git a/src/routes/openai.ts b/src/routes/openai.ts index 60fad19..d4c347c 100644 --- a/src/routes/openai.ts +++ b/src/routes/openai.ts @@ -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( diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 4f1e891..54b0cc5 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -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( } 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, ); diff --git a/src/services/logger.ts b/src/services/logger.ts index 3b335bd..93a0deb 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -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[]; diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx index a6dec06..1cb16bc 100644 --- a/src/views/dashboard/page.tsx +++ b/src/views/dashboard/page.tsx @@ -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() { '' + statusBadge + '' + '' + '' + log.provider + '' + + (log.provider === 'openai' ? 'bg-info/10 text-info' : log.provider === 'anthropic' ? 'bg-anthropic/10 text-anthropic' : 'bg-success/10 text-success') + '">' + log.provider + '' + '' + '' + log.model + '' + '' + langDisplay + '' +