From: Stefan Gasser Date: Fri, 16 Jan 2026 23:36:14 +0000 (+0100) Subject: Refactor config: providers.upstream → providers.openai, add wildcard proxy X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=c61a438c339a51004200ff2277bf6267db6c9dd8;p=sgasser-llm-shield.git Refactor config: providers.upstream → providers.openai, add wildcard proxy Config changes: - Rename providers.upstream to providers.openai for clarity - Remove routing config section (simplified to: PII → local, no PII → openai) - Move local provider to top-level config (not under providers) - Change default secrets action from block to redact Proxy changes: - Replace specific /models route with wildcard /* proxy - Supports all OpenAI endpoints: /models, /embeddings, /audio/*, etc. Documentation: - Update all docs to reflect new config structure - Remove docs/api-reference/models.mdx (now covered by wildcard proxy) --- diff --git a/CLAUDE.md b/CLAUDE.md index 1cddc1b..837c9ce 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 upstream. +OpenAI-compatible proxy with two privacy modes: route to local LLM or mask PII for configured provider. ## Tech Stack @@ -19,7 +19,7 @@ src/ ├── 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 @@ -42,8 +42,8 @@ Tests are colocated (`*.test.ts`). 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. diff --git a/README.md b/README.md index 7ba5333..a22be0d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ 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 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 diff --git a/config.example.yaml b/config.example.yaml index 7245246..cc923d8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,8 +3,8 @@ # 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 @@ -12,33 +12,27 @@ server: 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 @@ -95,11 +89,10 @@ secrets_detection: 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): @@ -155,7 +148,7 @@ logging: 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 @@ -166,4 +159,4 @@ dashboard: # Basic auth for dashboard (optional) # auth: # username: admin - # password: ${DASHBOARD_PASSWORD} \ No newline at end of file + # password: ${DASHBOARD_PASSWORD} diff --git a/docs/api-reference/chat-completions.mdx b/docs/api-reference/chat-completions.mdx index df01dc7..cd922be 100644 --- a/docs/api-reference/chat-completions.mdx +++ b/docs/api-reference/chat-completions.mdx @@ -5,12 +5,16 @@ description: POST /openai/v1/chat/completions # Chat Completions -Generate chat completions. Identical to OpenAI's endpoint. +Generate chat completions with automatic PII and secrets protection. ``` POST /openai/v1/chat/completions ``` + +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. + + ## Request ```bash @@ -112,7 +116,7 @@ PasteGuard adds headers to indicate PII and secrets handling: | 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 | diff --git a/docs/api-reference/dashboard-api.mdx b/docs/api-reference/dashboard-api.mdx index af02035..b23f33f 100644 --- a/docs/api-reference/dashboard-api.mdx +++ b/docs/api-reference/dashboard-api.mdx @@ -37,7 +37,7 @@ curl "http://localhost:3000/dashboard/api/logs?limit=100&offset=0" "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\"]", @@ -85,7 +85,7 @@ curl http://localhost:3000/dashboard/api/stats "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, diff --git a/docs/api-reference/models.mdx b/docs/api-reference/models.mdx deleted file mode 100644 index ca9fe05..0000000 --- a/docs/api-reference/models.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -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 - - - -```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); -} -``` - - diff --git a/docs/api-reference/status.mdx b/docs/api-reference/status.mdx index df338d4..f46d87f 100644 --- a/docs/api-reference/status.mdx +++ b/docs/api-reference/status.mdx @@ -72,7 +72,9 @@ curl http://localhost:3000/info "description": "Privacy proxy for LLMs", "mode": "mask", "providers": { - "upstream": { "type": "openai" } + "openai": { + "base_url": "https://api.openai.com/v1" + } }, "pii_detection": { "languages": ["en"], @@ -100,4 +102,4 @@ When language validation is available, `languages` becomes an object: } ``` -In route mode, `routing` and `providers.local` are also included. +In route mode, `local` provider info is also included. diff --git a/docs/concepts/mask-mode.mdx b/docs/concepts/mask-mode.mdx index 3d1ed52..27c2af8 100644 --- a/docs/concepts/mask-mode.mdx +++ b/docs/concepts/mask-mode.mdx @@ -5,7 +5,7 @@ description: Replace PII with placeholders before sending to your provider # 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 @@ -30,7 +30,7 @@ Mask mode replaces PII with placeholders before sending to your LLM provider. Th ## 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 @@ -38,8 +38,7 @@ Mask mode replaces PII with placeholders before sending to your LLM provider. Th mode: mask providers: - upstream: - type: openai + openai: base_url: https://api.openai.com/v1 ``` @@ -62,7 +61,7 @@ Mask mode sets these headers on responses: ``` 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 diff --git a/docs/concepts/route-mode.mdx b/docs/concepts/route-mode.mdx index 5cafa77..f72ff12 100644 --- a/docs/concepts/route-mode.mdx +++ b/docs/concepts/route-mode.mdx @@ -15,8 +15,8 @@ Route mode sends requests containing PII to a local LLM. Requests without PII go PII stays on your network. - - Routed to **Your Provider** (OpenAI, Azure, etc.) + + Routed to **Configured Provider** (OpenAI, Azure, self-hosted, etc.) Full provider performance. @@ -34,67 +34,56 @@ Route mode sends requests containing PII to a local LLM. Requests without PII go 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 @@ -110,11 +99,11 @@ X-PasteGuard-PII-Detected: true 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 ``` diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index fbe2b7f..0ba4291 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -61,7 +61,7 @@ Use `${VAR}` or `${VAR:-default}` syntax: ```yaml providers: - upstream: + openai: api_key: ${OPENAI_API_KEY} pii_detection: diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index 38c1a01..c806038 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -5,79 +5,70 @@ description: Configure your LLM providers # 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 | @@ -90,52 +81,47 @@ providers: ### 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 ``` diff --git a/docs/integrations.mdx b/docs/integrations.mdx index b1c6716..f9d67d0 100644 --- a/docs/integrations.mdx +++ b/docs/integrations.mdx @@ -130,5 +130,5 @@ curl -i http://localhost:3000/openai/v1/chat/completions \ Look for: ``` X-PasteGuard-Mode: mask -X-PasteGuard-Provider: upstream +X-PasteGuard-Provider: openai ``` diff --git a/docs/introduction.mdx b/docs/introduction.mdx index d5afdb9..5383b53 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -5,7 +5,7 @@ description: Privacy proxy for LLMs # 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 diff --git a/docs/mint.json b/docs/mint.json index f0b077b..a73e45c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -49,7 +49,6 @@ "group": "API Reference", "pages": [ "api-reference/chat-completions", - "api-reference/models", "api-reference/status", "api-reference/dashboard-api" ] diff --git a/src/config.ts b/src/config.ts index 10bd6ad..0cfe236 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,11 +4,18 @@ import { z } from "zod"; // 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({ @@ -16,11 +23,6 @@ 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 = [ @@ -114,21 +116,16 @@ const SecretsDetectionSchema = z.object({ 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({}), @@ -137,14 +134,14 @@ const ConfigSchema = z }) .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( @@ -162,8 +159,8 @@ const ConfigSchema = z ); export type Config = z.infer; -export type UpstreamProvider = z.infer; -export type LocalProvider = z.infer; +export type OpenAIProviderConfig = 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 d073156..003ea38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,18 +159,18 @@ function printStartupBanner(config: ReturnType, host: string, 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(` ╔═══════════════════════════════════════════════════════════╗ diff --git a/src/routes/info.ts b/src/routes/info.ts index d8fbf98..7b3ab3d 100644 --- a/src/routes/info.ts +++ b/src/routes/info.ts @@ -19,8 +19,8 @@ infoRoutes.get("/info", (c) => { description: "Privacy proxy for LLMs", mode: config.mode, providers: { - upstream: { - type: providers.upstream.type, + openai: { + base_url: providers.openai.baseUrl, }, }, pii_detection: { @@ -37,16 +37,11 @@ infoRoutes.get("/info", (c) => { }, }; - 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).local = { - type: providers.local.type, - }; - } } if (config.mode === "mask") { diff --git a/src/routes/proxy.ts b/src/routes/proxy.ts index 4700d2b..915b758 100644 --- a/src/routes/proxy.ts +++ b/src/routes/proxy.ts @@ -64,7 +64,7 @@ function createErrorLogData( 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 @@ -83,16 +83,6 @@ function createErrorLogData( }; } -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 */ @@ -144,7 +134,7 @@ proxyRoutes.post( { 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: [], @@ -357,7 +347,7 @@ async function handleCompletion( ) { 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; @@ -608,3 +598,19 @@ function formatMessagesForLog(messages: ChatMessage[]): string { }) .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"), + }, + }); +}); diff --git a/src/services/decision.test.ts b/src/services/decision.test.ts index d6e1b90..3d4985a 100644 --- a/src/services/decision.test.ts +++ b/src/services/decision.test.ts @@ -8,10 +8,9 @@ import type { PIIDetectionResult } from "./pii-detector"; */ 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); @@ -24,13 +23,13 @@ function decideRoute( 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", }; } @@ -60,85 +59,47 @@ function createPIIResult( } 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); }); }); @@ -157,14 +118,12 @@ function createSecretsResult( } 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"); @@ -173,15 +132,10 @@ describe("decideRoute with secrets", () => { }); 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"); @@ -191,19 +145,19 @@ describe("decideRoute with secrets", () => { 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"); }); }); @@ -213,10 +167,10 @@ describe("decideRoute with secrets", () => { 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"); }); }); @@ -226,10 +180,10 @@ describe("decideRoute with secrets", () => { 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"); }); }); @@ -243,7 +197,7 @@ describe("decideRoute with secrets", () => { { 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"); diff --git a/src/services/decision.ts b/src/services/decision.ts index 9be2b5a..da1bdbf 100644 --- a/src/services/decision.ts +++ b/src/services/decision.ts @@ -9,7 +9,7 @@ import { getPIIDetector, type PIIDetectionResult } from "../services/pii-detecto */ export interface RouteDecision { mode: "route"; - provider: "upstream" | "local"; + provider: "openai" | "local"; reason: string; piiResult: PIIDetectionResult; } @@ -19,7 +19,7 @@ export interface RouteDecision { */ export interface MaskDecision { mode: "mask"; - provider: "upstream"; + provider: "openai"; reason: string; piiResult: PIIDetectionResult; maskedMessages: ChatMessage[]; @@ -30,19 +30,19 @@ export type RoutingDecision = RouteDecision | MaskDecision; /** * 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; } @@ -76,17 +76,14 @@ export class Router { /** * 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); @@ -103,16 +100,16 @@ export class Router { 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, }; @@ -125,7 +122,7 @@ export class Router { if (!piiResult.hasPII) { return { mode: "mask", - provider: "upstream", + provider: "openai", reason: "No PII detected", piiResult, maskedMessages: messages, @@ -139,7 +136,7 @@ export class Router { return { mode: "mask", - provider: "upstream", + provider: "openai", reason: `PII masked: ${entityTypes.join(", ")}`, piiResult, maskedMessages: masked, @@ -147,14 +144,14 @@ export class Router { }; } - 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; } /** @@ -187,7 +184,7 @@ export class Router { getProvidersInfo() { return { mode: this.config.mode, - upstream: this.upstreamClient.getInfo(), + openai: this.openaiClient.getInfo(), local: this.localClient?.getInfo() ?? null, }; } diff --git a/src/services/llm-client.ts b/src/services/llm-client.ts index 4120add..a7467e4 100644 --- a/src/services/llm-client.ts +++ b/src/services/llm-client.ts @@ -1,4 +1,4 @@ -import type { LocalProvider, UpstreamProvider } from "../config"; +import type { LocalProviderConfig, OpenAIProviderConfig } from "../config"; import type { MessageContent } from "../utils/content"; /** @@ -49,13 +49,13 @@ export type LLMResult = isStreaming: true; response: ReadableStream; model: string; - provider: "upstream" | "local"; + provider: "openai" | "local"; } | { isStreaming: false; response: ChatCompletionResponse; model: string; - provider: "upstream" | "local"; + provider: "openai" | "local"; }; /** @@ -79,17 +79,19 @@ export class LLMClient { 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; } @@ -97,10 +99,10 @@ export class LLMClient { /** * 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 { - // 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; @@ -188,7 +190,7 @@ export class LLMClient { } } - 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, diff --git a/src/services/logger.ts b/src/services/logger.ts index 0d86880..3b335bd 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: "upstream" | "local"; + provider: "openai" | "local"; model: string; pii_detected: boolean; entities: string; @@ -32,7 +32,7 @@ export interface Stats { 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; @@ -170,8 +170,8 @@ export class Logger { .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'`) @@ -206,7 +206,7 @@ export class Logger { 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, @@ -282,7 +282,7 @@ export function getLogger(): Logger { export interface RequestLogData { timestamp: string; mode: "route" | "mask"; - provider: "upstream" | "local"; + provider: "openai" | "local"; model: string; piiDetected: boolean; entities: string[]; diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx index 931a444..a6dec06 100644 --- a/src/views/dashboard/page.tsx +++ b/src/views/dashboard/page.tsx @@ -253,9 +253,9 @@ const StatsGrid: FC = () => ( @@ -454,15 +454,15 @@ async function fetchStats() { } 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 = - '
' + upstreamPct + '%
' + + '
' + openaiPct + '%
' + '
' + localPct + '%
'; } @@ -592,7 +592,7 @@ async function fetchLogs() { '' + statusBadge + '' + '' + '' + log.provider + '' + + (log.provider === 'openai' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '' + '' + '' + log.model + '' + '' + langDisplay + '' +