# PasteGuard
-OpenAI-compatible proxy with two privacy modes: route to local LLM or mask PII for upstream.
+OpenAI-compatible proxy with two privacy modes: route to local LLM or mask PII for configured provider.
## Tech Stack
├── index.ts # Hono server entry
├── config.ts # YAML config + Zod validation
├── routes/
-│ ├── proxy.ts # POST /openai/v1/chat/completions
+│ ├── proxy.ts # /openai/v1/* (chat completions + wildcard proxy)
│ ├── dashboard.tsx # Dashboard routes + API
│ ├── health.ts # GET /health
│ └── info.ts # GET /info
Two modes configured in `config.yaml`:
-- **Route**: Routes PII-containing requests to local LLM (requires `local` provider + `routing` config)
-- **Mask**: Masks PII before upstream, unmasks response (no local provider needed)
+- **Route**: Routes PII-containing requests to local LLM (requires `local` provider config)
+- **Mask**: Masks PII before sending to configured provider, unmasks response (no local provider needed)
See @config.example.yaml for full configuration.
You receive: "Dear Dr. Sarah Chen, Following up on our discussion..."
```
-PasteGuard sits between your app and the LLM provider. It's OpenAI-compatible — just change the base URL.
+PasteGuard sits between your app and your provider. It's OpenAI-compatible — just change the base URL.
## Quick Start
# Privacy mode: "mask" or "route"
#
-# mask: Masks PII before sending to upstream, unmasks in response (no local LLM needed)
-# route: Routes requests to local LLM when PII detected (requires local provider)
+# mask: Masks PII before sending to provider, unmasks in response
+# route: Routes PII requests to local LLM, non-PII to configured provider
mode: mask
# Server settings
port: 3000
host: "0.0.0.0"
-# LLM Provider configuration
+# Providers - OpenAI-compatible API endpoints
+# Can be cloud (OpenAI, Azure) or self-hosted (vLLM, LiteLLM proxy, etc.)
providers:
- # Upstream provider (required for both modes)
- # The proxy forwards your client's Authorization header to the upstream provider
- # You can optionally set api_key here as a fallback
- upstream:
- type: openai
+ # OpenAI-compatible endpoint (required)
+ # The proxy forwards your client's Authorization header
+ openai:
base_url: https://api.openai.com/v1
# api_key: ${OPENAI_API_KEY} # Optional fallback if client doesn't send auth header
- # Local provider (only for route mode - can be removed if using mask mode)
- # Supports: ollama, openai (for OpenAI-compatible servers like LocalAI, LM Studio)
- local:
- type: ollama # or "openai" for OpenAI-compatible servers
- base_url: http://localhost:11434
- model: llama3.2 # All PII requests use this model
- # api_key: ${LOCAL_API_KEY} # Only needed for OpenAI-compatible servers
-
-# Routing rules (only for route mode - can be removed if using mask mode)
-routing:
- # Default provider when no PII is detected
- default: upstream
-
- # Provider to use when PII is detected
- on_pii_detected: local
-
-# Masking settings (only for mask mode - can be removed if using route mode)
+# Local provider - only for route mode
+# PII requests are sent here instead of the configured provider
+# Supports: ollama (native), openai (for vLLM, LocalAI, LM Studio, etc.)
+#
+# Uncomment for route mode:
+# local:
+# type: ollama # or "openai" for OpenAI-compatible servers
+# base_url: http://localhost:11434
+# model: llama3.2
+# # api_key: ${LOCAL_API_KEY} # Only needed for OpenAI-compatible servers
+
+# Masking settings (only for mask mode)
masking:
# Add visual markers to unmasked values in response (for debugging/demos)
# Interferes with copy/paste, so disabled by default
enabled: true
# Action to take when secrets are detected:
+ # redact: Replace secrets with placeholders, unmask in response (default)
# block: Block the request with HTTP 400
- # redact: Replace secrets with placeholders, unmask in response
# route_local: Route to local provider (only works in route mode)
- # Default: redact (if not specified)
- action: block
+ action: redact
# Secret types to detect
# Private Keys (enabled by default):
log_content: false
# Log masked content for dashboard preview (default: true)
- # Shows what was actually sent to upstream LLM with PII replaced by placeholders
+ # Shows what was actually sent to provider with PII replaced by placeholders
# Disable if you don't want any content stored, even masked
log_masked_content: true
# Basic auth for dashboard (optional)
# auth:
# username: admin
- # password: ${DASHBOARD_PASSWORD}
\ No newline at end of file
+ # password: ${DASHBOARD_PASSWORD}
# Chat Completions
-Generate chat completions. Identical to OpenAI's endpoint.
+Generate chat completions with automatic PII and secrets protection.
```
POST /openai/v1/chat/completions
```
+<Note>
+This is the only endpoint that receives PII detection and masking. All other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) are proxied directly to OpenAI without modification.
+</Note>
+
## Request
```bash
| Header | Description |
|--------|-------------|
| `X-PasteGuard-Mode` | Current mode (`mask` or `route`) |
-| `X-PasteGuard-Provider` | Provider used (`upstream` or `local`) |
+| `X-PasteGuard-Provider` | Provider used (`openai` 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 |
"id": 1,
"timestamp": "2026-01-15T10:30:00Z",
"mode": "mask",
- "provider": "upstream",
+ "provider": "openai",
"model": "gpt-5.2",
"pii_detected": true,
"entities": "[\"EMAIL_ADDRESS\",\"PERSON\"]",
"total_requests": 1500,
"pii_requests": 342,
"pii_percentage": 22.8,
- "upstream_requests": 1200,
+ "openai_requests": 1200,
"local_requests": 300,
"avg_scan_time_ms": 45,
"total_tokens": 125000,
+++ /dev/null
----
-title: Models
-description: GET /openai/v1/models
----
-
-# Models
-
-List available models from your configured provider.
-
-```
-GET /openai/v1/models
-```
-
-## Request
-
-```bash
-curl http://localhost:3000/openai/v1/models \
- -H "Authorization: Bearer $OPENAI_API_KEY"
-```
-
-## Response
-
-```json
-{
- "object": "list",
- "data": [
- {"id": "gpt-5.2", "object": "model", "owned_by": "openai"}
- ]
-}
-```
-
-## SDK Usage
-
-<CodeGroup>
-
-```python Python
-from openai import OpenAI
-
-client = OpenAI(base_url="http://localhost:3000/openai/v1")
-
-models = client.models.list()
-for model in models:
- print(model.id)
-```
-
-```javascript JavaScript
-import OpenAI from 'openai';
-
-const client = new OpenAI({
- baseURL: 'http://localhost:3000/openai/v1'
-});
-
-const models = await client.models.list();
-for (const model of models.data) {
- console.log(model.id);
-}
-```
-
-</CodeGroup>
"description": "Privacy proxy for LLMs",
"mode": "mask",
"providers": {
- "upstream": { "type": "openai" }
+ "openai": {
+ "base_url": "https://api.openai.com/v1"
+ }
},
"pii_detection": {
"languages": ["en"],
}
```
-In route mode, `routing` and `providers.local` are also included.
+In route mode, `local` provider info is also included.
# Mask Mode
-Mask mode replaces PII with placeholders before sending to your LLM provider. The response is automatically unmasked before returning to you.
+Mask mode replaces PII with placeholders before sending to your configured provider. The response is automatically unmasked before returning to you.
## How It Works
## When to Use
- Simple setup without local infrastructure
-- Want to use external LLM providers while protecting PII
+- Want to use any OpenAI-compatible provider while protecting PII
## Configuration
mode: mask
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.openai.com/v1
```
```
X-PasteGuard-Mode: mask
-X-PasteGuard-Provider: upstream
+X-PasteGuard-Provider: openai
X-PasteGuard-PII-Detected: true
X-PasteGuard-PII-Masked: true
X-PasteGuard-Language: en
PII stays on your network.
</Card>
- <Card title="Request without PII" icon="cloud">
- Routed to **Your Provider** (OpenAI, Azure, etc.)
+ <Card title="Request without PII" icon="server">
+ Routed to **Configured Provider** (OpenAI, Azure, self-hosted, etc.)
Full provider performance.
</Card>
mode: route
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.openai.com/v1
- local:
- type: ollama
- base_url: http://localhost:11434
- model: llama3.2
-
-routing:
- default: upstream
- on_pii_detected: local
-```
-### Routing Options
+local:
+ type: ollama
+ base_url: http://localhost:11434
+ model: llama3.2
+```
-| Option | Description |
-|--------|-------------|
-| `default` | Provider for requests without PII |
-| `on_pii_detected` | Provider for requests with PII |
+In route mode:
+- **No PII detected** → Request goes to configured provider (openai)
+- **PII detected** → Request goes to local provider
## Local Provider Setup
### Ollama
```yaml
-providers:
- local:
- type: ollama
- base_url: http://localhost:11434
- model: llama3.2
+local:
+ type: ollama
+ base_url: http://localhost:11434
+ model: llama3.2
```
### vLLM
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8000/v1
- model: meta-llama/Llama-2-7b-chat-hf
+local:
+ type: openai
+ base_url: http://localhost:8000/v1
+ model: meta-llama/Llama-2-7b-chat-hf
```
### llama.cpp
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8080/v1
- model: local
+local:
+ type: openai
+ base_url: http://localhost:8080/v1
+ model: local
```
### LocalAI
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8080/v1
- model: your-model-name
- api_key: ${LOCAL_API_KEY} # if required
+local:
+ type: openai
+ base_url: http://localhost:8080/v1
+ model: your-model-name
+ api_key: ${LOCAL_API_KEY} # if required
```
## Response Headers
X-PasteGuard-Language: en
```
-When routed to your provider:
+When routed to configured provider:
```
X-PasteGuard-Mode: route
-X-PasteGuard-Provider: upstream
+X-PasteGuard-Provider: openai
X-PasteGuard-PII-Detected: false
X-PasteGuard-Language: en
```
```yaml
providers:
- upstream:
+ openai:
api_key: ${OPENAI_API_KEY}
pii_detection:
# Providers
-PasteGuard supports two provider types: your configured provider (upstream) and local.
+PasteGuard supports two provider types: configured providers (`providers`) and local provider (`local`).
-## Upstream Provider
+## Providers
-Required for both modes. Your LLM provider (OpenAI, Azure, etc.).
+Required for both modes. Any OpenAI-compatible endpoint works — cloud services (OpenAI, Azure, OpenRouter) or self-hosted (LiteLLM proxy, vLLM).
```yaml
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.openai.com/v1
# api_key: ${OPENAI_API_KEY} # Optional fallback
```
| Option | Description |
|--------|-------------|
-| `type` | `openai` |
-| `base_url` | API endpoint |
+| `base_url` | API endpoint (any OpenAI-compatible URL) |
| `api_key` | Optional. Used if client doesn't send Authorization header |
-### Supported Providers
+### Supported Endpoints
Any OpenAI-compatible API works:
```yaml
# OpenAI
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.openai.com/v1
# Azure OpenAI
providers:
- upstream:
- type: openai
+ openai:
base_url: https://your-resource.openai.azure.com/openai/v1
# OpenRouter
providers:
- upstream:
- type: openai
+ openai:
base_url: https://openrouter.ai/api/v1
api_key: ${OPENROUTER_API_KEY}
-# LiteLLM Proxy
+# LiteLLM Proxy (self-hosted)
providers:
- upstream:
- type: openai
- base_url: http://localhost:4000 # LiteLLM default port
+ openai:
+ base_url: http://localhost:4000
# Together AI
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.together.xyz/v1
# Groq
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.groq.com/openai/v1
```
## Local Provider
-Required for Route mode only. Your local LLM.
+Required for route mode only. Your local LLM for PII requests.
```yaml
-providers:
- local:
- type: ollama
- base_url: http://localhost:11434
- model: llama3.2
+local:
+ type: ollama
+ base_url: http://localhost:11434
+ model: llama3.2
```
| Option | Description |
### Ollama
```yaml
-providers:
- local:
- type: ollama
- base_url: http://localhost:11434
- model: llama3.2
+local:
+ type: ollama
+ base_url: http://localhost:11434
+ model: llama3.2
```
### vLLM
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8000/v1
- model: meta-llama/Llama-2-7b-chat-hf
+local:
+ type: openai
+ base_url: http://localhost:8000/v1
+ model: meta-llama/Llama-2-7b-chat-hf
```
### llama.cpp
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8080/v1
- model: local
+local:
+ type: openai
+ base_url: http://localhost:8080/v1
+ model: local
```
### LocalAI
```yaml
-providers:
- local:
- type: openai
- base_url: http://localhost:8080/v1
- model: your-model
- api_key: ${LOCAL_API_KEY} # if required
+local:
+ type: openai
+ base_url: http://localhost:8080/v1
+ model: your-model
+ api_key: ${LOCAL_API_KEY} # if required
```
## API Key Handling
-PasteGuard forwards your client's `Authorization` header to your provider. You can optionally set `api_key` in config as a fallback:
+PasteGuard forwards your client's `Authorization` header to the configured provider. You can optionally set `api_key` in config as a fallback:
```yaml
providers:
- upstream:
- type: openai
+ openai:
base_url: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY} # Used if client doesn't send auth
```
Look for:
```
X-PasteGuard-Mode: mask
-X-PasteGuard-Provider: upstream
+X-PasteGuard-Provider: openai
```
# What is PasteGuard?
-PasteGuard is an OpenAI-compatible proxy that protects personal data and secrets before sending to your LLM provider (OpenAI, Azure, etc.).
+PasteGuard is an OpenAI-compatible proxy that protects personal data and secrets before sending to your provider (OpenAI, Azure, self-hosted, etc.).
## The Problem
"group": "API Reference",
"pages": [
"api-reference/chat-completions",
- "api-reference/models",
"api-reference/status",
"api-reference/dashboard-api"
]
// Schema definitions
+// Local provider - for route mode when PII is detected
const LocalProviderSchema = z.object({
- type: z.enum(["openai", "ollama"]),
+ type: z.enum(["openai", "ollama"]), // ollama native or openai-compatible (vLLM, LocalAI, etc.)
api_key: z.string().optional(),
base_url: z.string().url(),
- model: z.string(), // Required: maps incoming model to local model
+ model: z.string(), // Required: all PII requests use this model
+});
+
+// Providers - OpenAI-compatible endpoints (cloud or self-hosted)
+const OpenAIProviderSchema = z.object({
+ base_url: z.string().url().default("https://api.openai.com/v1"),
+ api_key: z.string().optional(), // Optional fallback if client doesn't send auth header
});
const MaskingSchema = z.object({
marker_text: z.string().default("[protected]"),
});
-const RoutingSchema = z.object({
- default: z.enum(["upstream", "local"]),
- on_pii_detected: z.enum(["upstream", "local"]),
-});
-
// All 25 spaCy languages with trained pipelines
// See presidio/languages.yaml for full list
const SupportedLanguages = [
log_detected_types: z.boolean().default(true),
});
-const UpstreamProviderSchema = z.object({
- type: z.enum(["openai"]),
- api_key: z.string().optional(),
- base_url: z.string().url(),
-});
-
const ConfigSchema = z
.object({
mode: z.enum(["route", "mask"]).default("route"),
server: ServerSchema.default({}),
+ // Providers - OpenAI-compatible endpoints
providers: z.object({
- upstream: UpstreamProviderSchema,
- local: LocalProviderSchema.optional(),
+ openai: OpenAIProviderSchema.default({}),
}),
- routing: RoutingSchema.optional(),
+ // Local provider - only for route mode
+ local: LocalProviderSchema.optional(),
masking: MaskingSchema.default({}),
pii_detection: PIIDetectionSchema,
logging: LoggingSchema.default({}),
})
.refine(
(config) => {
- // Route mode requires local provider and routing config
+ // Route mode requires local provider
if (config.mode === "route") {
- return config.providers.local !== undefined && config.routing !== undefined;
+ return config.local !== undefined;
}
return true;
},
{
- message: "Route mode requires 'providers.local' and 'routing' configuration",
+ message: "Route mode requires 'local' provider configuration",
},
)
.refine(
);
export type Config = z.infer<typeof ConfigSchema>;
-export type UpstreamProvider = z.infer<typeof UpstreamProviderSchema>;
-export type LocalProvider = z.infer<typeof LocalProviderSchema>;
+export type OpenAIProviderConfig = z.infer<typeof OpenAIProviderSchema>;
+export type LocalProviderConfig = z.infer<typeof LocalProviderSchema>;
export type MaskingConfig = z.infer<typeof MaskingSchema>;
export type SecretsDetectionConfig = z.infer<typeof SecretsDetectionSchema>;
config.mode === "route"
? `
Routing:
- Default: ${config.routing?.default || "upstream"}
- On PII: ${config.routing?.on_pii_detected || "local"}
+ No PII: openai (configured)
+ On PII: local
Providers:
- Upstream: ${config.providers.upstream.type}
- Local: ${config.providers.local?.type || "not configured"} → ${config.providers.local?.model || "n/a"}`
+ OpenAI: ${config.providers.openai.base_url}
+ Local: ${config.local?.type || "not configured"} → ${config.local?.model || "n/a"}`
: `
Masking:
Markers: ${config.masking.show_markers ? "enabled" : "disabled"}
Provider:
- Upstream: ${config.providers.upstream.type}`;
+ OpenAI: ${config.providers.openai.base_url}`;
console.log(`
╔═══════════════════════════════════════════════════════════╗
description: "Privacy proxy for LLMs",
mode: config.mode,
providers: {
- upstream: {
- type: providers.upstream.type,
+ openai: {
+ base_url: providers.openai.baseUrl,
},
},
pii_detection: {
},
};
- if (config.mode === "route" && config.routing) {
- info.routing = {
- default: config.routing.default,
- on_pii_detected: config.routing.on_pii_detected,
+ if (config.mode === "route" && providers.local) {
+ info.local = {
+ type: providers.local.type,
+ base_url: providers.local.baseUrl,
};
- if (providers.local) {
- (info.providers as Record<string, unknown>).local = {
- type: providers.local.type,
- };
- }
}
if (config.mode === "mask") {
return {
timestamp: new Date().toISOString(),
mode: decision?.mode ?? config.mode,
- provider: decision?.provider ?? "upstream",
+ provider: decision?.provider ?? "openai",
model: body.model || "unknown",
piiDetected: decision?.piiResult.hasPII ?? false,
entities: decision
};
}
-proxyRoutes.get("/models", (c) => {
- const { upstream } = getRouter().getProvidersInfo();
-
- return proxy(`${upstream.baseUrl}/models`, {
- headers: {
- Authorization: c.req.header("Authorization"),
- },
- });
-});
-
/**
* POST /v1/chat/completions - OpenAI-compatible chat completion endpoint
*/
{
timestamp: new Date().toISOString(),
mode: config.mode,
- provider: "upstream", // Note: Request never reached provider
+ provider: "openai", // Note: Request never reached provider
model: body.model || "unknown",
piiDetected: false,
entities: [],
) {
const client = router.getClient(decision.provider);
const maskingConfig = router.getMaskingConfig();
- const authHeader = decision.provider === "upstream" ? c.req.header("Authorization") : undefined;
+ const authHeader = decision.provider === "openai" ? c.req.header("Authorization") : undefined;
// Prepare request and masked content for logging
let request: ChatCompletionRequest = body;
})
.join("\n");
}
+
+/**
+ * Wildcard proxy - forwards all other /v1/* requests to the configured provider
+ * Supports: /models, /embeddings, /audio/*, /images/*, /files/*, etc.
+ * Must be defined AFTER specific routes to avoid matching them first
+ */
+proxyRoutes.all("/*", (c) => {
+ const { openai } = getRouter().getProvidersInfo();
+ const path = c.req.path.replace(/^\/openai\/v1/, "");
+
+ return proxy(`${openai.baseUrl}${path}`, {
+ headers: {
+ Authorization: c.req.header("Authorization"),
+ },
+ });
+});
*/
function decideRoute(
piiResult: PIIDetectionResult,
- routing: { default: "upstream" | "local"; on_pii_detected: "upstream" | "local" },
secretsResult?: SecretsDetectionResult,
secretsAction?: "block" | "redact" | "route_local",
-): { provider: "upstream" | "local"; reason: string } {
+): { provider: "openai" | "local"; reason: string } {
// Check for secrets route_local action first (takes precedence)
if (secretsResult?.detected && secretsAction === "route_local") {
const secretTypes = secretsResult.matches.map((m) => m.type);
if (piiResult.hasPII) {
const entityTypes = [...new Set(piiResult.newEntities.map((e) => e.entity_type))];
return {
- provider: routing.on_pii_detected,
+ provider: "local",
reason: `PII detected: ${entityTypes.join(", ")}`,
};
}
return {
- provider: routing.default,
+ provider: "openai",
reason: "No PII detected",
};
}
}
describe("decideRoute", () => {
- describe("with default=upstream, on_pii_detected=local", () => {
- const routing = { default: "upstream" as const, on_pii_detected: "local" as const };
-
- test("routes to upstream when no PII detected", () => {
- const result = decideRoute(createPIIResult(false), routing);
-
- expect(result.provider).toBe("upstream");
- expect(result.reason).toBe("No PII detected");
- });
-
- test("routes to local when PII detected", () => {
- const result = decideRoute(createPIIResult(true, [{ entity_type: "PERSON" }]), routing);
-
- expect(result.provider).toBe("local");
- expect(result.reason).toContain("PII detected");
- expect(result.reason).toContain("PERSON");
- });
-
- test("includes all entity types in reason", () => {
- const result = decideRoute(
- createPIIResult(true, [
- { entity_type: "PERSON" },
- { entity_type: "EMAIL_ADDRESS" },
- { entity_type: "PHONE_NUMBER" },
- ]),
- routing,
- );
-
- expect(result.reason).toContain("PERSON");
- expect(result.reason).toContain("EMAIL_ADDRESS");
- expect(result.reason).toContain("PHONE_NUMBER");
- });
+ test("routes to openai when no PII detected", () => {
+ const result = decideRoute(createPIIResult(false));
- test("deduplicates entity types in reason", () => {
- const result = decideRoute(
- createPIIResult(true, [
- { entity_type: "PERSON" },
- { entity_type: "PERSON" },
- { entity_type: "PERSON" },
- ]),
- routing,
- );
-
- // Should only contain PERSON once
- const matches = result.reason.match(/PERSON/g);
- expect(matches?.length).toBe(1);
- });
+ expect(result.provider).toBe("openai");
+ expect(result.reason).toBe("No PII detected");
});
- describe("with default=local, on_pii_detected=upstream", () => {
- const routing = { default: "local" as const, on_pii_detected: "upstream" as const };
-
- test("routes to local when no PII detected", () => {
- const result = decideRoute(createPIIResult(false), routing);
-
- expect(result.provider).toBe("local");
- expect(result.reason).toBe("No PII detected");
- });
-
- test("routes to upstream when PII detected", () => {
- const result = decideRoute(
- createPIIResult(true, [{ entity_type: "EMAIL_ADDRESS" }]),
- routing,
- );
+ test("routes to local when PII detected", () => {
+ const result = decideRoute(createPIIResult(true, [{ entity_type: "PERSON" }]));
- expect(result.provider).toBe("upstream");
- expect(result.reason).toContain("PII detected");
- });
+ expect(result.provider).toBe("local");
+ expect(result.reason).toContain("PII detected");
+ expect(result.reason).toContain("PERSON");
});
- describe("with same provider for both cases", () => {
- const routing = { default: "upstream" as const, on_pii_detected: "upstream" as const };
+ test("includes all entity types in reason", () => {
+ const result = decideRoute(
+ createPIIResult(true, [
+ { entity_type: "PERSON" },
+ { entity_type: "EMAIL_ADDRESS" },
+ { entity_type: "PHONE_NUMBER" },
+ ]),
+ );
+
+ expect(result.reason).toContain("PERSON");
+ expect(result.reason).toContain("EMAIL_ADDRESS");
+ expect(result.reason).toContain("PHONE_NUMBER");
+ });
- test("always routes to upstream regardless of PII", () => {
- expect(decideRoute(createPIIResult(false), routing).provider).toBe("upstream");
- expect(
- decideRoute(createPIIResult(true, [{ entity_type: "PERSON" }]), routing).provider,
- ).toBe("upstream");
- });
+ test("deduplicates entity types in reason", () => {
+ const result = decideRoute(
+ createPIIResult(true, [
+ { entity_type: "PERSON" },
+ { entity_type: "PERSON" },
+ { entity_type: "PERSON" },
+ ]),
+ );
+
+ // Should only contain PERSON once
+ const matches = result.reason.match(/PERSON/g);
+ expect(matches?.length).toBe(1);
});
});
}
describe("decideRoute with secrets", () => {
- const routing = { default: "upstream" as const, on_pii_detected: "local" as const };
-
describe("with route_local action", () => {
test("routes to local when secrets detected", () => {
const piiResult = createPIIResult(false);
const secretsResult = createSecretsResult(true, [{ type: "API_KEY_OPENAI", count: 1 }]);
- const result = decideRoute(piiResult, routing, secretsResult, "route_local");
+ const result = decideRoute(piiResult, secretsResult, "route_local");
expect(result.provider).toBe("local");
expect(result.reason).toContain("Secrets detected");
});
test("secrets routing takes precedence over PII routing", () => {
- // Even with on_pii_detected=upstream, secrets route_local should go to local
- const routingUpstream = {
- default: "upstream" as const,
- on_pii_detected: "upstream" as const,
- };
const piiResult = createPIIResult(true, [{ entity_type: "PERSON" }]);
const secretsResult = createSecretsResult(true, [{ type: "API_KEY_AWS", count: 1 }]);
- const result = decideRoute(piiResult, routingUpstream, secretsResult, "route_local");
+ const result = decideRoute(piiResult, secretsResult, "route_local");
expect(result.provider).toBe("local");
expect(result.reason).toContain("Secrets detected");
const piiResult = createPIIResult(true, [{ entity_type: "EMAIL_ADDRESS" }]);
const secretsResult = createSecretsResult(false);
- const result = decideRoute(piiResult, routing, secretsResult, "route_local");
+ const result = decideRoute(piiResult, secretsResult, "route_local");
- expect(result.provider).toBe("local"); // PII detected -> on_pii_detected=local
+ expect(result.provider).toBe("local"); // PII detected -> local
expect(result.reason).toContain("PII detected");
});
- test("routes to default when no secrets and no PII detected", () => {
+ test("routes to openai when no secrets and no PII detected", () => {
const piiResult = createPIIResult(false);
const secretsResult = createSecretsResult(false);
- const result = decideRoute(piiResult, routing, secretsResult, "route_local");
+ const result = decideRoute(piiResult, secretsResult, "route_local");
- expect(result.provider).toBe("upstream");
+ expect(result.provider).toBe("openai");
expect(result.reason).toBe("No PII detected");
});
});
const piiResult = createPIIResult(false);
const secretsResult = createSecretsResult(true, [{ type: "JWT_TOKEN", count: 1 }]);
- const result = decideRoute(piiResult, routing, secretsResult, "block");
+ const result = decideRoute(piiResult, secretsResult, "block");
// With block action, we shouldn't route based on secrets
- expect(result.provider).toBe("upstream");
+ expect(result.provider).toBe("openai");
expect(result.reason).toBe("No PII detected");
});
});
const piiResult = createPIIResult(false);
const secretsResult = createSecretsResult(true, [{ type: "BEARER_TOKEN", count: 1 }]);
- const result = decideRoute(piiResult, routing, secretsResult, "redact");
+ const result = decideRoute(piiResult, secretsResult, "redact");
// With redact action, we route based on PII, not secrets
- expect(result.provider).toBe("upstream");
+ expect(result.provider).toBe("openai");
expect(result.reason).toBe("No PII detected");
});
});
{ type: "JWT_TOKEN", count: 1 },
]);
- const result = decideRoute(piiResult, routing, secretsResult, "route_local");
+ const result = decideRoute(piiResult, secretsResult, "route_local");
expect(result.reason).toContain("API_KEY_OPENAI");
expect(result.reason).toContain("API_KEY_GITHUB");
*/
export interface RouteDecision {
mode: "route";
- provider: "upstream" | "local";
+ provider: "openai" | "local";
reason: string;
piiResult: PIIDetectionResult;
}
*/
export interface MaskDecision {
mode: "mask";
- provider: "upstream";
+ provider: "openai";
reason: string;
piiResult: PIIDetectionResult;
maskedMessages: ChatMessage[];
/**
* Router that decides how to handle requests based on PII detection
- * Supports two modes: route (to local LLM) or mask (anonymize for upstream)
+ * Supports two modes: route (to local LLM) or mask (anonymize for provider)
*/
export class Router {
- private upstreamClient: LLMClient;
+ private openaiClient: LLMClient;
private localClient: LLMClient | null;
private config: Config;
constructor() {
this.config = getConfig();
- this.upstreamClient = new LLMClient(this.config.providers.upstream, "upstream");
- this.localClient = this.config.providers.local
- ? new LLMClient(this.config.providers.local, "local", this.config.providers.local.model)
+ this.openaiClient = new LLMClient(this.config.providers.openai, "openai");
+ this.localClient = this.config.local
+ ? new LLMClient(this.config.local, "local", this.config.local.model)
: null;
}
/**
* Route mode: decides which provider to use
*
- * Secrets routing takes precedence over PII routing when action is route_local
+ * - No PII/Secrets → use configured provider (openai)
+ * - PII detected → use local provider
+ * - Secrets detected with route_local action → use local provider (takes precedence)
*/
private decideRoute(
piiResult: PIIDetectionResult,
secretsResult?: SecretsDetectionResult,
): RouteDecision {
- const routing = this.config.routing;
- if (!routing) {
- throw new Error("Route mode requires routing configuration");
- }
-
// Check for secrets route_local action first (takes precedence)
if (secretsResult?.detected && this.config.secrets_detection.action === "route_local") {
const secretTypes = secretsResult.matches.map((m) => m.type);
const entityTypes = [...new Set(piiResult.newEntities.map((e) => e.entity_type))];
return {
mode: "route",
- provider: routing.on_pii_detected,
+ provider: "local",
reason: `PII detected: ${entityTypes.join(", ")}`,
piiResult,
};
}
- // No PII detected, use default provider
+ // No PII detected, use configured provider
return {
mode: "route",
- provider: routing.default,
+ provider: "openai",
reason: "No PII detected",
piiResult,
};
if (!piiResult.hasPII) {
return {
mode: "mask",
- provider: "upstream",
+ provider: "openai",
reason: "No PII detected",
piiResult,
maskedMessages: messages,
return {
mode: "mask",
- provider: "upstream",
+ provider: "openai",
reason: `PII masked: ${entityTypes.join(", ")}`,
piiResult,
maskedMessages: masked,
};
}
- getClient(provider: "upstream" | "local"): LLMClient {
+ getClient(provider: "openai" | "local"): LLMClient {
if (provider === "local") {
if (!this.localClient) {
throw new Error("Local provider not configured");
}
return this.localClient;
}
- return this.upstreamClient;
+ return this.openaiClient;
}
/**
getProvidersInfo() {
return {
mode: this.config.mode,
- upstream: this.upstreamClient.getInfo(),
+ openai: this.openaiClient.getInfo(),
local: this.localClient?.getInfo() ?? null,
};
}
-import type { LocalProvider, UpstreamProvider } from "../config";
+import type { LocalProviderConfig, OpenAIProviderConfig } from "../config";
import type { MessageContent } from "../utils/content";
/**
isStreaming: true;
response: ReadableStream<Uint8Array>;
model: string;
- provider: "upstream" | "local";
+ provider: "openai" | "local";
}
| {
isStreaming: false;
response: ChatCompletionResponse;
model: string;
- provider: "upstream" | "local";
+ provider: "openai" | "local";
};
/**
private baseUrl: string;
private apiKey?: string;
private providerType: "openai" | "ollama";
- private providerName: "upstream" | "local";
+ private providerName: "openai" | "local";
private defaultModel?: string;
constructor(
- provider: UpstreamProvider | LocalProvider,
- providerName: "upstream" | "local",
+ provider: OpenAIProviderConfig | LocalProviderConfig,
+ providerName: "openai" | "local",
defaultModel?: string,
) {
this.baseUrl = provider.base_url.replace(/\/$/, "");
this.apiKey = provider.api_key;
- this.providerType = provider.type;
+ // Configured providers (openai) always use openai protocol
+ // Local providers specify their type (ollama or openai-compatible)
+ this.providerType = "type" in provider ? provider.type : "openai";
this.providerName = providerName;
this.defaultModel = defaultModel;
}
/**
* Sends a chat completion request
* @param request The chat completion request
- * @param authHeader Optional Authorization header from client (forwarded for upstream)
+ * @param authHeader Optional Authorization header from client (forwarded for openai provider)
*/
async chatCompletion(request: ChatCompletionRequest, authHeader?: string): Promise<LLMResult> {
- // Local uses configured model, upstream uses request model
+ // Local uses configured model, openai uses request model
const model = this.defaultModel || request.model;
const isStreaming = request.stream ?? false;
}
}
- getInfo(): { name: "upstream" | "local"; type: "openai" | "ollama"; baseUrl: string } {
+ getInfo(): { name: "openai" | "local"; type: "openai" | "ollama"; baseUrl: string } {
return {
name: this.providerName,
type: this.providerType,
id?: number;
timestamp: string;
mode: "route" | "mask";
- provider: "upstream" | "local";
+ provider: "openai" | "local";
model: string;
pii_detected: boolean;
entities: string;
total_requests: number;
pii_requests: number;
pii_percentage: number;
- upstream_requests: number;
+ openai_requests: number;
local_requests: number;
avg_scan_time_ms: number;
total_tokens: number;
.get() as { count: number };
// Upstream vs Local
- const upstreamResult = this.db
- .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'upstream'`)
+ const openaiResult = this.db
+ .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'openai'`)
.get() as { count: number };
const localResult = this.db
.prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'local'`)
total_requests: total,
pii_requests: pii,
pii_percentage: total > 0 ? Math.round((pii / total) * 100 * 10) / 10 : 0,
- upstream_requests: upstreamResult.count,
+ openai_requests: openaiResult.count,
local_requests: localResult.count,
avg_scan_time_ms: Math.round(scanTimeResult.avg || 0),
total_tokens: tokensResult.total,
export interface RequestLogData {
timestamp: string;
mode: "route" | "mask";
- provider: "upstream" | "local";
+ provider: "openai" | "local";
model: string;
piiDetected: boolean;
entities: string[];
<StatCard label="Avg PII Scan" valueId="avg-scan" accent="teal" />
<StatCard label="Requests/Hour" valueId="requests-hour" />
<StatCard
- id="upstream-card"
- label="Upstream"
- valueId="upstream-requests"
+ id="openai-card"
+ label="OpenAI"
+ valueId="openai-requests"
accent="info"
routeOnly
/>
}
if (data.mode === 'route') {
- document.getElementById('upstream-requests').textContent = data.upstream_requests.toLocaleString();
+ document.getElementById('openai-requests').textContent = data.openai_requests.toLocaleString();
document.getElementById('local-requests').textContent = data.local_requests.toLocaleString();
- const total = data.upstream_requests + data.local_requests;
- const upstreamPct = total > 0 ? Math.round((data.upstream_requests / total) * 100) : 50;
- const localPct = 100 - upstreamPct;
+ const total = data.openai_requests + data.local_requests;
+ const openaiPct = total > 0 ? Math.round((data.openai_requests / total) * 100) : 50;
+ const localPct = 100 - openaiPct;
document.getElementById('provider-split').innerHTML =
- '<div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-info min-w-[48px] transition-all" style="width:' + Math.max(upstreamPct, 10) + '%">' + upstreamPct + '%</div>' +
+ '<div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-info min-w-[48px] transition-all" style="width:' + Math.max(openaiPct, 10) + '%">' + openaiPct + '%</div>' +
'<div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-success min-w-[48px] transition-all" style="width:' + Math.max(localPct, 10) + '%">' + localPct + '%</div>';
}
'<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + statusBadge + '</td>' +
'<td class="route-only text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
'<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide ' +
- (log.provider === 'upstream' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
+ (log.provider === 'openai' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
'</td>' +
'<td class="font-mono text-[0.7rem] text-text-secondary px-4 py-3 border-b border-border-subtle align-middle">' + log.model + '</td>' +
'<td class="font-mono text-[0.65rem] font-medium px-4 py-3 border-b border-border-subtle align-middle">' + langDisplay + '</td>' +