Initial release
authorStefan Gasser <redacted>
Thu, 8 Jan 2026 10:14:12 +0000 (11:14 +0100)
committerStefan Gasser <redacted>
Thu, 8 Jan 2026 10:14:12 +0000 (11:14 +0100)
OpenAI-compatible privacy proxy with two modes:
- Mask: Replace PII with placeholders before upstream, unmask in response
- Route: Send PII-containing requests to local LLM

Features:
- 24 language support for PII detection
- Real-time streaming with unmasking
- Dashboard for monitoring
- Microsoft Presidio integration

37 files changed:
.github/workflows/ci.yml [new file with mode: 0644]
.gitignore [new file with mode: 0644]
CLAUDE.md [new file with mode: 0644]
CONTRIBUTING.md [new file with mode: 0644]
Dockerfile [new file with mode: 0644]
LICENSE [new file with mode: 0644]
README.md [new file with mode: 0644]
biome.json [new file with mode: 0644]
bun.lock [new file with mode: 0644]
config.example.yaml [new file with mode: 0644]
docker-compose.yml [new file with mode: 0644]
docs/dashboard.png [new file with mode: 0644]
package.json [new file with mode: 0644]
presidio/Dockerfile [new file with mode: 0644]
presidio/languages.yaml [new file with mode: 0644]
presidio/scripts/generate-configs.py [new file with mode: 0644]
src/config.ts [new file with mode: 0644]
src/index.ts [new file with mode: 0644]
src/routes/chat.test.ts [new file with mode: 0644]
src/routes/chat.ts [new file with mode: 0644]
src/routes/dashboard.tsx [new file with mode: 0644]
src/routes/health.test.ts [new file with mode: 0644]
src/routes/health.ts [new file with mode: 0644]
src/routes/info.test.ts [new file with mode: 0644]
src/routes/info.ts [new file with mode: 0644]
src/services/decision.test.ts [new file with mode: 0644]
src/services/decision.ts [new file with mode: 0644]
src/services/language-detector.ts [new file with mode: 0644]
src/services/llm-client.ts [new file with mode: 0644]
src/services/logger.ts [new file with mode: 0644]
src/services/masking.test.ts [new file with mode: 0644]
src/services/masking.ts [new file with mode: 0644]
src/services/pii-detector.ts [new file with mode: 0644]
src/services/stream-transformer.test.ts [new file with mode: 0644]
src/services/stream-transformer.ts [new file with mode: 0644]
src/views/dashboard/page.tsx [new file with mode: 0644]
tsconfig.json [new file with mode: 0644]

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644 (file)
index 0000000..921a10f
--- /dev/null
@@ -0,0 +1,41 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test & Lint
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: latest
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Type check
+        run: bun run typecheck
+
+      - name: Lint & format check
+        run: bun run check
+
+      - name: Run tests
+        run: bun test
+
+  docker-build:
+    name: Docker Build Test
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Test Docker build
+        run: docker build -t llm-shield:test .
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..7c2007f
--- /dev/null
@@ -0,0 +1,40 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Config (user-specific)
+config.yaml
+config.yml
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+data/
+
+# Logs
+logs/
+*.log
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+
+# Bun
+bun.lockb
+
+# Test
+coverage/
+test-*.ts
+test-*.sh
+test-*.js
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644 (file)
index 0000000..b881b4e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,86 @@
+# LLM-Shield
+
+OpenAI-compatible proxy with two privacy modes: route to local LLM or mask PII for upstream.
+
+## Tech Stack
+
+- Runtime: Bun
+- Framework: Hono (with JSX for dashboard)
+- Validation: Zod
+- Styling: Tailwind CSS v4
+- Database: SQLite (`data/llm-shield.db`)
+- PII Detection: Microsoft Presidio (Docker)
+- Code Style: Biome (see @biome.json)
+
+## Architecture
+
+```
+src/
+├── index.ts                 # Hono server entry
+├── config.ts                # YAML config + Zod validation
+├── schemas/
+│   └── chat.ts              # Request/response schemas
+├── routes/                  # All route handlers
+│   ├── chat.ts              # POST /openai/v1/chat/completions
+│   ├── dashboard.tsx        # Dashboard routes + API
+│   ├── health.ts            # GET /health
+│   └── info.ts              # GET /info
+├── views/                   # JSX components
+│   └── dashboard/
+│       └── page.tsx         # Dashboard UI
+└── services/
+    ├── decision.ts             # Route/mask logic
+    ├── pii-detector.ts         # Presidio client
+    ├── llm-client.ts           # OpenAI/Ollama client
+    ├── masking.ts              # PII mask/unmask
+    ├── stream-transformer.ts   # SSE unmask for streaming
+    ├── language-detector.ts    # Auto language detection
+    └── logger.ts               # SQLite logging
+```
+
+Tests are colocated (`*.test.ts`).
+
+## Modes
+
+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)
+
+See @config.example.yaml for full configuration.
+
+## Commands
+
+- `bun run dev` - Development (hot reload)
+- `bun run start` - Production
+- `bun run build` - Build to dist/
+- `bun test` - Run tests
+- `bun run typecheck` - Type check
+- `bun run lint` - Lint only
+- `bun run check` - Lint + format check
+- `bun run format` - Format code
+
+## Setup
+
+**Production:** `docker compose up -d`
+
+**Development:**
+```bash
+cp config.example.yaml config.yaml
+docker compose up presidio-analyzer -d
+bun install && bun run dev
+```
+
+**Dependencies:**
+- Presidio (port 5002) - required
+- Ollama (port 11434) - route mode only
+
+**Multi-language PII:** Build with `LANGUAGES=en,de,fr docker compose build`. See @presidio/languages.yaml for 24 available languages.
+
+## Testing
+
+- `GET /health` - Health check
+- `GET /info` - Mode info
+- `POST /openai/v1/chat/completions` - Main endpoint
+
+Response header `X-LLM-Shield-PII-Masked: true` indicates PII was masked.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644 (file)
index 0000000..808761f
--- /dev/null
@@ -0,0 +1,48 @@
+# Contributing to LLM-Shield
+
+Thank you for considering contributing to LLM-Shield!
+
+## Development Setup
+
+1. Fork and clone the repository
+2. Install dependencies: `bun install`
+3. Copy config: `cp config.example.yaml config.yaml`
+4. Start Presidio: `docker compose up presidio-analyzer -d`
+5. Run dev server: `bun run dev`
+
+## Code Quality
+
+Before submitting a PR, ensure:
+
+```bash
+# Type checking passes
+bun run typecheck
+
+# Linting and formatting pass
+bun run check
+
+# Format code if needed
+bun run format
+```
+
+## Pull Request Process
+
+1. Create a feature branch from `main`
+2. Make your changes
+3. Ensure all checks pass
+4. Submit a PR with a clear description
+
+## Code Style
+
+- Use TypeScript strict mode
+- Follow existing code patterns
+- Keep functions focused and small
+- Add JSDoc comments for public APIs
+
+## Reporting Issues
+
+When reporting issues, please include:
+- Steps to reproduce
+- Expected behavior
+- Actual behavior
+- Environment (Bun version, OS)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..3e2741e
--- /dev/null
@@ -0,0 +1,15 @@
+FROM oven/bun:1-slim
+
+WORKDIR /app
+
+# Install dependencies
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile --production
+
+# Copy source
+COPY src ./src
+COPY tsconfig.json ./
+
+EXPOSE 3000
+
+CMD ["bun", "run", "src/index.ts"]
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..65ce82e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,190 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to the Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   Copyright 2026 Stefan Gasser
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..1bd2793
--- /dev/null
+++ b/README.md
@@ -0,0 +1,234 @@
+# 🛡️ LLM-Shield
+
+[![CI](https://github.com/sgasser/llm-shield/actions/workflows/ci.yml/badge.svg)](https://github.com/sgasser/llm-shield/actions/workflows/ci.yml)
+[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
+
+Privacy proxy for LLMs. Masks personal data before sending to your provider (OpenAI, Azure, etc.), or routes sensitive requests to local LLM.
+
+<img src="docs/dashboard.png" width="100%" alt="LLM-Shield Dashboard">
+
+## Mask Mode (Default)
+
+Replaces personal data with placeholders before sending to LLM. Unmasks the response automatically.
+
+```
+You send:              "Email john@acme.com about the meeting with Sarah Miller"
+OpenAI receives:       "Email <EMAIL_1> about the meeting with <PERSON_1>"
+OpenAI responds:       "I'll contact <EMAIL_1> to schedule with <PERSON_1>..."
+You receive:           "I'll contact john@acme.com to schedule with Sarah Miller..."
+```
+
+- No local GPU needed
+- Supports streaming with real-time unmasking
+
+## Route Mode
+
+Requests with personal data go to local LLM. Everything else goes to your provider.
+
+```
+"Help with code review"              → OpenAI (best quality)
+"Email john@acme.com about..."       → Ollama (stays on your network)
+```
+
+- Requires local LLM (Ollama, vLLM, LocalAI)
+- Full data isolation - personal data never leaves your network
+
+## What It Detects
+
+| Type | Examples |
+|------|----------|
+| Names | John Smith, Sarah Miller |
+| Emails | john@acme.com |
+| Phone numbers | +1 555 123 4567 |
+| Credit cards | 4111-1111-1111-1111 |
+| IBANs | DE89 3704 0044 0532 0130 00 |
+| IP addresses | 192.168.1.1 |
+| Locations | New York, Berlin |
+
+Additional entity types can be enabled: `US_SSN`, `US_PASSPORT`, `CRYPTO`, `NRP`, `MEDICAL_LICENSE`, `URL`.
+
+**Languages**: 24 languages supported (configurable at build time). Auto-detected per request.
+
+Powered by [Microsoft Presidio](https://microsoft.github.io/presidio/).
+
+## Quick Start
+
+### Docker (recommended)
+
+```bash
+git clone https://github.com/sgasser/llm-shield.git
+cd llm-shield
+cp config.example.yaml config.yaml
+
+# Option 1: English only (default, ~1.5GB)
+docker compose up -d
+
+# Option 2: Multiple languages (~2.5GB)
+# Edit config.yaml to add languages, then:
+LANGUAGES=en,de,fr,es,it docker compose up -d
+```
+
+### Local Development
+
+```bash
+git clone https://github.com/sgasser/llm-shield.git
+cd llm-shield
+bun install
+cp config.example.yaml config.yaml
+
+# Option 1: English only (default)
+docker compose up presidio-analyzer -d
+
+# Option 2: Multiple languages
+# Edit config.yaml to add languages, then:
+LANGUAGES=en,de,fr,es,it docker compose build presidio-analyzer
+docker compose up presidio-analyzer -d
+
+bun run dev
+```
+
+Dashboard: http://localhost:3000/dashboard
+
+**Usage:** Point your app to `http://localhost:3000/openai/v1` instead of `https://api.openai.com/v1`.
+
+## Language Configuration
+
+By default, only English is installed to minimize image size. Add more languages at build time:
+
+```bash
+# English only (default, smallest image ~1.5GB)
+docker compose build
+
+# English + German
+LANGUAGES=en,de docker compose build
+
+# Multiple languages
+LANGUAGES=en,de,fr,it,es docker compose build
+```
+
+**Available languages (24):**
+`ca`, `zh`, `hr`, `da`, `nl`, `en`, `fi`, `fr`, `de`, `el`, `it`, `ja`, `ko`, `lt`, `mk`, `nb`, `pl`, `pt`, `ro`, `ru`, `sl`, `es`, `sv`, `uk`
+
+**Language Fallback Behavior:**
+- Text language is auto-detected for each request
+- If detected language is not installed, falls back to `fallback_language` (default: `en`)
+- Dashboard shows fallback as `FR→EN` when French text is detected but only English is installed
+- Response header `X-LLM-Shield-Language-Fallback: true` indicates fallback was used
+
+Update `config.yaml` to match your installed languages:
+
+```yaml
+pii_detection:
+  languages:
+    - en
+    - de
+```
+
+See [presidio/languages.yaml](presidio/languages.yaml) for full details including context words.
+
+## Configuration
+
+**Mask mode:**
+
+```yaml
+mode: mask
+providers:
+  upstream:
+    type: openai
+    base_url: https://api.openai.com/v1
+masking:
+  placeholder_format: "<{TYPE}_{N}>"  # Format for masked values
+  show_markers: false                  # Add visual markers to unmasked values
+```
+
+**Route mode:**
+
+```yaml
+mode: route
+providers:
+  upstream:
+    type: openai
+    base_url: https://api.openai.com/v1
+  local:
+    type: ollama
+    base_url: http://localhost:11434
+    model: llama3.2                   # Model for all local requests
+routing:
+  default: upstream
+  on_pii_detected: local
+```
+
+**Customize detection:**
+
+```yaml
+pii_detection:
+  score_threshold: 0.7        # Confidence (0.0 - 1.0)
+  entities:                   # What to detect
+    - PERSON
+    - EMAIL_ADDRESS
+    - PHONE_NUMBER
+    - CREDIT_CARD
+    - IBAN_CODE
+```
+
+**Logging options:**
+
+```yaml
+logging:
+  database: ./data/llm-shield.db
+  retention_days: 30           # 0 = keep forever
+  log_content: false           # Log full request/response
+  log_masked_content: true     # Log masked content for dashboard
+```
+
+**Dashboard authentication:**
+
+```yaml
+dashboard:
+  auth:
+    username: admin
+    password: ${DASHBOARD_PASSWORD}
+```
+
+**Environment variables:** Config values support `${VAR}` and `${VAR:-default}` substitution.
+
+See [config.example.yaml](config.example.yaml) for all options.
+
+## API Reference
+
+**Endpoints:**
+
+| Endpoint | Description |
+|----------|-------------|
+| `POST /openai/v1/chat/completions` | Chat API (OpenAI-compatible) |
+| `GET /openai/v1/models` | List models |
+| `GET /dashboard` | Monitoring UI |
+| `GET /dashboard/api/logs` | Request logs (JSON) |
+| `GET /dashboard/api/stats` | Statistics (JSON) |
+| `GET /health` | Health check |
+| `GET /info` | Current configuration |
+
+**Response headers:**
+
+| Header | Value |
+|--------|-------|
+| `X-Request-ID` | Request identifier (forwarded or generated) |
+| `X-LLM-Shield-Mode` | `route` / `mask` |
+| `X-LLM-Shield-PII-Detected` | `true` / `false` |
+| `X-LLM-Shield-PII-Masked` | `true` / `false` (mask mode) |
+| `X-LLM-Shield-Provider` | `upstream` / `local` |
+| `X-LLM-Shield-Language` | Detected language code |
+| `X-LLM-Shield-Language-Fallback` | `true` if fallback was used |
+
+## Development
+
+```bash
+docker compose up presidio-analyzer -d    # Start detection service
+bun run dev                               # Dev server with hot reload
+bun test                                  # Run tests
+bun run check                             # Lint & format
+```
+
+## License
+
+[Apache 2.0](LICENSE)
diff --git a/biome.json b/biome.json
new file mode 100644 (file)
index 0000000..530e667
--- /dev/null
@@ -0,0 +1,37 @@
+{
+  "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+  "vcs": {
+    "enabled": true,
+    "clientKind": "git",
+    "useIgnoreFile": true
+  },
+  "files": {
+    "ignoreUnknown": false,
+    "includes": ["src/**/*.ts"]
+  },
+  "formatter": {
+    "enabled": true,
+    "indentStyle": "space",
+    "indentWidth": 2,
+    "lineWidth": 100
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "recommended": true,
+      "complexity": {
+        "noForEach": "off"
+      },
+      "style": {
+        "noNonNullAssertion": "off"
+      }
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "quoteStyle": "double",
+      "semicolons": "always",
+      "trailingCommas": "all"
+    }
+  }
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644 (file)
index 0000000..3a80089
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,164 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "llm-shield",
+      "dependencies": {
+        "@hono/zod-validator": "^0.7.6",
+        "eld": "^2.0.1",
+        "hono": "^4.11.0",
+        "hono-tailwind": "^2.2.0",
+        "tailwindcss": "^4.1.18",
+        "yaml": "^2.7.0",
+        "zod": "^3.24.0",
+      },
+      "devDependencies": {
+        "@biomejs/biome": "^2.3.11",
+        "@types/bun": "latest",
+        "typescript": "^5.7.0",
+      },
+    },
+  },
+  "packages": {
+    "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+    "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
+
+    "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
+
+    "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
+
+    "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
+
+    "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
+
+    "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
+
+    "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
+
+    "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
+
+    "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
+
+    "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
+
+    "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+    "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+    "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+    "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+    "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+    "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
+
+    "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
+
+    "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
+
+    "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
+
+    "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
+
+    "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
+
+    "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
+
+    "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
+
+    "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
+
+    "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
+
+    "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
+
+    "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
+
+    "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
+
+    "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
+
+    "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
+
+    "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
+
+    "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
+
+    "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
+
+    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+    "eld": ["eld@2.0.1", "", {}, "sha512-Lo+M5M7IL/N3MSXMbnfBrdsn+qu0rScPyOA/POvxKU7HsLEOfFOJuEBC96vmYxMJShxXtH+wnWVOhgu+rf7u9A=="],
+
+    "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
+
+    "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+    "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
+
+    "hono-tailwind": ["hono-tailwind@2.2.0", "", { "dependencies": { "@tailwindcss/postcss": "^4.1.6", "postcss": "^8.5.3" }, "peerDependencies": { "hono": "^4.0.0", "tailwindcss": "^4.1.6" } }, "sha512-orA97f08l2nsKU4tu4EAa4/F5KIIscdvsFQkFi07U0WYCpCVEy+Pw4qAY+z4jYgHf7OSnXo7IzLwFxTq4tRH9Q=="],
+
+    "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+    "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
+
+    "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
+
+    "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
+
+    "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
+
+    "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
+
+    "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
+
+    "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
+
+    "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
+
+    "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
+
+    "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
+
+    "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
+
+    "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
+
+    "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+    "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+    "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+    "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
+
+    "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+    "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+    "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
+
+    "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+  }
+}
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644 (file)
index 0000000..98fb32b
--- /dev/null
@@ -0,0 +1,115 @@
+# LLM-Shield Configuration
+# Copy this file to config.yaml and adjust the values
+
+# 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)
+mode: mask
+
+# Server settings
+server:
+  port: 3000
+  host: "0.0.0.0"
+
+# LLM Provider configuration
+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
+    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)
+masking:
+  # Add visual markers to unmasked values in response (for debugging/demos)
+  # Interferes with copy/paste, so disabled by default
+  show_markers: false
+  marker_text: "[protected]"
+
+# PII Detection settings (Microsoft Presidio)
+pii_detection:
+  presidio_url: ${PRESIDIO_URL:-http://localhost:5002}
+
+  # Supported languages for PII detection
+  # Auto-detects language from input text and uses appropriate model
+  # If only one language is specified, language detection is skipped
+  #
+  # Languages must match what was installed during docker build:
+  #   LANGUAGES=en,de docker-compose build
+  #
+  # Available (24 languages): ca, zh, hr, da, nl, en, fi, fr, de, el,
+  #   it, ja, ko, lt, mk, nb, pl, pt, ro, ru, sl, es, sv, uk
+  # See presidio/languages.yaml for full list with details
+  languages:
+    - en
+    # Add more languages to match your Docker build:
+    # - de
+    # - fr
+    # - es
+    # - it
+
+  # Fallback language if detected language is not in the list above
+  fallback_language: en
+
+  score_threshold: 0.7  # Minimum confidence score (0.0 - 1.0)
+
+  # Entity types to detect
+  # See: https://microsoft.github.io/presidio/supported_entities/
+  entities:
+    - PERSON
+    - EMAIL_ADDRESS
+    - PHONE_NUMBER
+    - CREDIT_CARD
+    - IBAN_CODE
+    - IP_ADDRESS
+    - LOCATION
+    # - US_SSN
+    # - US_PASSPORT
+    # - CRYPTO
+    # - NRP  # National Registration Number
+    # - MEDICAL_LICENSE
+    # - URL
+
+# Logging settings
+logging:
+  # SQLite database for request logs
+  database: ./data/llm-shield.db
+
+  # Log retention in days (0 = keep forever)
+  retention_days: 30
+
+  # Log request/response content (may contain sensitive data!)
+  log_content: false
+
+  # Log masked content for dashboard preview (default: true)
+  # Shows what was actually sent to upstream LLM with PII replaced by tokens
+  # Disable if you don't want any content stored, even masked
+  log_masked_content: true
+
+# Dashboard settings
+dashboard:
+  enabled: true
+
+  # Basic auth for dashboard (optional)
+  # auth:
+  #   username: admin
+  #   password: ${DASHBOARD_PASSWORD}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644 (file)
index 0000000..8fceec9
--- /dev/null
@@ -0,0 +1,34 @@
+services:
+  llm-shield:
+    build: .
+    ports:
+      - "3000:3000"
+    environment:
+      - PRESIDIO_URL=http://presidio-analyzer:3000
+    volumes:
+      - ./config.yaml:/app/config.yaml:ro
+      - ./data:/app/data
+    depends_on:
+      presidio-analyzer:
+        condition: service_healthy
+    restart: unless-stopped
+
+  presidio-analyzer:
+    build:
+      context: ./presidio
+      args:
+        # Languages to install for PII detection
+        # Available: ca, zh, hr, da, nl, en, fi, fr, de, el, it, ja, ko,
+        #            lt, mk, nb, pl, pt, ro, ru, sl, es, sv, uk
+        # See presidio/languages.yaml for full list
+        # Example: LANGUAGES=en,de,fr docker-compose build
+        LANGUAGES: ${LANGUAGES:-en}
+    ports:
+      - "5002:3000"
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
+      interval: 30s
+      timeout: 10s
+      retries: 5
+      start_period: 60s
+    restart: unless-stopped
diff --git a/docs/dashboard.png b/docs/dashboard.png
new file mode 100644 (file)
index 0000000..24eca40
Binary files /dev/null and b/docs/dashboard.png differ
diff --git a/package.json b/package.json
new file mode 100644 (file)
index 0000000..4921794
--- /dev/null
@@ -0,0 +1,46 @@
+{
+  "name": "llm-shield",
+  "version": "0.1.0",
+  "description": "Intelligent privacy-aware routing for LLMs - OpenAI-compatible proxy that routes requests based on PII detection",
+  "type": "module",
+  "main": "src/index.ts",
+  "scripts": {
+    "dev": "bun run --hot src/index.ts",
+    "start": "bun run src/index.ts",
+    "build": "bun build src/index.ts --outdir dist --target bun --external lightningcss",
+    "test": "bun test",
+    "typecheck": "tsc --noEmit",
+    "lint": "biome lint src",
+    "format": "biome format src --write",
+    "check": "biome check src"
+  },
+  "dependencies": {
+    "@hono/zod-validator": "^0.7.6",
+    "eld": "^2.0.1",
+    "hono": "^4.11.0",
+    "hono-tailwind": "^2.2.0",
+    "tailwindcss": "^4.1.18",
+    "yaml": "^2.7.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@biomejs/biome": "^2.3.11",
+    "@types/bun": "latest",
+    "typescript": "^5.7.0"
+  },
+  "keywords": [
+    "llm",
+    "privacy",
+    "pii",
+    "openai",
+    "proxy",
+    "gdpr",
+    "routing"
+  ],
+  "author": "Stefan Gasser",
+  "license": "Apache-2.0",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/sgasser/llm-shield"
+  }
+}
diff --git a/presidio/Dockerfile b/presidio/Dockerfile
new file mode 100644 (file)
index 0000000..f3799c9
--- /dev/null
@@ -0,0 +1,49 @@
+# LLM-Shield Presidio Analyzer
+# Multi-language PII detection with configurable language support
+#
+# Build with specific languages:
+#   docker build --build-arg LANGUAGES=en,de,fr -t presidio-analyzer .
+#
+# Or via docker-compose:
+#   LANGUAGES=en,de docker-compose build presidio-analyzer
+
+ARG LANGUAGES="en"
+
+# =============================================================================
+# Stage 1: Generate configuration files from language selection
+# =============================================================================
+FROM python:3.11-slim AS generator
+
+WORKDIR /build
+
+# Install PyYAML for config generation
+RUN pip install --no-cache-dir pyyaml
+
+# Copy registry and generator script
+COPY languages.yaml /build/
+COPY scripts/generate-configs.py /build/
+
+# Generate configs for selected languages
+ARG LANGUAGES
+RUN python generate-configs.py \
+    --languages="${LANGUAGES}" \
+    --registry=/build/languages.yaml \
+    --output=/output
+
+# =============================================================================
+# Stage 2: Final Presidio Analyzer image
+# =============================================================================
+FROM mcr.microsoft.com/presidio-analyzer:latest
+
+# Copy generated configuration files
+COPY --from=generator /output/nlp-config.yaml /usr/bin/presidio_analyzer/conf/default.yaml
+COPY --from=generator /output/recognizers-config.yaml /usr/bin/presidio_analyzer/conf/default_recognizers.yaml
+COPY --from=generator /output/analyzer-config.yaml /usr/bin/presidio_analyzer/conf/default_analyzer.yaml
+
+# Copy and run model installation script
+COPY --from=generator /output/install-models.sh /tmp/
+RUN chmod +x /tmp/install-models.sh && /tmp/install-models.sh && rm /tmp/install-models.sh
+
+# Use --preload to load models once in master process (shared via copy-on-write)
+# Timeout 300s for initial model loading, workers start fast after preload
+CMD ["/bin/sh", "-c", "poetry run gunicorn -w $WORKERS -b 0.0.0.0:$PORT --timeout 300 --preload 'app:create_app()'"]
diff --git a/presidio/languages.yaml b/presidio/languages.yaml
new file mode 100644 (file)
index 0000000..266aa36
--- /dev/null
@@ -0,0 +1,223 @@
+# LLM-Shield Language Registry
+# All 24 spaCy languages with trained pipelines
+#
+# Usage: Set LANGUAGES build arg to select which to install
+#   LANGUAGES=en,de docker-compose build
+#
+# To add a custom language, add an entry here with model name
+
+spacy_version: "3.8.0"
+
+languages:
+  # Catalan
+  ca:
+    name: Catalan
+    model: ca_core_news_md
+
+  # Chinese
+  zh:
+    name: Chinese
+    model: zh_core_web_md
+
+  # Croatian
+  hr:
+    name: Croatian
+    model: hr_core_news_md
+
+  # Danish
+  da:
+    name: Danish
+    model: da_core_news_md
+
+  # Dutch
+  nl:
+    name: Dutch
+    model: nl_core_news_md
+    phone_context:
+      - telefoon
+      - telefoonnummer
+      - mobiel
+      - bellen
+      - fax
+
+  # English
+  en:
+    name: English
+    model: en_core_web_lg
+    phone_context:
+      - phone
+      - telephone
+      - cell
+      - mobile
+      - call
+      - fax
+
+  # Finnish
+  fi:
+    name: Finnish
+    model: fi_core_news_md
+
+  # French
+  fr:
+    name: French
+    model: fr_core_news_md
+    phone_context:
+      - téléphone
+      - portable
+      - mobile
+      - numéro
+      - appeler
+      - fax
+
+  # German
+  de:
+    name: German
+    model: de_core_news_md
+    phone_context:
+      - telefon
+      - telefonnummer
+      - handy
+      - mobil
+      - mobilnummer
+      - fax
+      - anrufen
+
+  # Greek
+  el:
+    name: Greek
+    model: el_core_news_md
+    phone_context:
+      - τηλέφωνο
+      - κινητό
+      - φαξ
+
+  # Italian
+  it:
+    name: Italian
+    model: it_core_news_md
+    phone_context:
+      - telefono
+      - cellulare
+      - mobile
+      - numero
+      - chiamare
+      - fax
+
+  # Japanese
+  ja:
+    name: Japanese
+    model: ja_core_news_md
+    phone_context:
+      - 電話
+      - 携帯
+      - モバイル
+      - ファックス
+
+  # Korean
+  ko:
+    name: Korean
+    model: ko_core_news_md
+    phone_context:
+      - 전화
+      - 휴대폰
+      - 모바일
+      - 팩스
+
+  # Lithuanian
+  lt:
+    name: Lithuanian
+    model: lt_core_news_md
+
+  # Macedonian
+  mk:
+    name: Macedonian
+    model: mk_core_news_md
+
+  # Norwegian Bokmål
+  nb:
+    name: Norwegian
+    model: nb_core_news_md
+    phone_context:
+      - telefon
+      - mobil
+      - ringe
+      - faks
+
+  # Polish
+  pl:
+    name: Polish
+    model: pl_core_news_md
+    phone_context:
+      - telefon
+      - komórka
+      - dzwonić
+      - faks
+
+  # Portuguese
+  pt:
+    name: Portuguese
+    model: pt_core_news_md
+    phone_context:
+      - telefone
+      - celular
+      - móvel
+      - ligar
+      - fax
+
+  # Romanian
+  ro:
+    name: Romanian
+    model: ro_core_news_md
+    phone_context:
+      - telefon
+      - mobil
+      - apel
+      - fax
+
+  # Russian
+  ru:
+    name: Russian
+    model: ru_core_news_md
+    phone_context:
+      - телефон
+      - мобильный
+      - звонить
+      - факс
+
+  # Slovenian
+  sl:
+    name: Slovenian
+    model: sl_core_news_md
+
+  # Spanish
+  es:
+    name: Spanish
+    model: es_core_news_md
+    phone_context:
+      - teléfono
+      - móvil
+      - celular
+      - número
+      - llamar
+      - fax
+
+  # Swedish
+  sv:
+    name: Swedish
+    model: sv_core_news_md
+    phone_context:
+      - telefon
+      - mobil
+      - ringa
+      - fax
+
+  # Ukrainian
+  uk:
+    name: Ukrainian
+    model: uk_core_news_md
+    phone_context:
+      - телефон
+      - мобільний
+      - дзвонити
+      - факс
+
diff --git a/presidio/scripts/generate-configs.py b/presidio/scripts/generate-configs.py
new file mode 100644 (file)
index 0000000..02be7b4
--- /dev/null
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+Generate Presidio configuration files from selected languages.
+
+Usage:
+    python generate-configs.py --languages=en,de --output=/output
+
+Reads from languages.yaml and generates:
+    - nlp-config.yaml
+    - recognizers-config.yaml
+    - analyzer-config.yaml
+    - install-models.sh
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+import yaml
+
+
+def load_registry(registry_path: Path) -> dict:
+    """Load the language registry."""
+    with open(registry_path) as f:
+        return yaml.safe_load(f)
+
+
+def validate_languages(languages: list[str], registry: dict) -> list[str]:
+    """Validate requested languages exist in registry."""
+    available = set(registry["languages"].keys())
+    valid = []
+    invalid = []
+
+    for lang in languages:
+        if lang in available:
+            valid.append(lang)
+        else:
+            invalid.append(lang)
+
+    if invalid:
+        print(f"Error: Unknown language(s): {', '.join(invalid)}", file=sys.stderr)
+        print(f"Available: {', '.join(sorted(available))}", file=sys.stderr)
+        sys.exit(1)
+
+    return valid
+
+
+def generate_nlp_config(languages: list[str], registry: dict) -> dict:
+    """Generate nlp-config.yaml content."""
+    models = []
+    for lang in languages:
+        lang_config = registry["languages"][lang]
+        models.append({"lang_code": lang, "model_name": lang_config["model"]})
+
+    return {
+        "nlp_engine_name": "spacy",
+        "models": models,
+        "ner_model_configuration": {
+            "model_to_presidio_entity_mapping": {
+                "PER": "PERSON",
+                "PERSON": "PERSON",
+                "LOC": "LOCATION",
+                "GPE": "LOCATION",
+                "ORG": "ORGANIZATION",
+            },
+            "low_confidence_score_multiplier": 0.4,
+            "low_score_entity_names": ["ORG"],
+            "labels_to_ignore": [
+                "O",
+                "CARDINAL",
+                "EVENT",
+                "LANGUAGE",
+                "LAW",
+                "MONEY",
+                "ORDINAL",
+                "PERCENT",
+                "PRODUCT",
+                "QUANTITY",
+                "WORK_OF_ART",
+            ],
+        },
+    }
+
+
+def generate_analyzer_config(languages: list[str]) -> dict:
+    """Generate analyzer-config.yaml content."""
+    return {"supported_languages": languages, "default_score_threshold": 0}
+
+
+def generate_recognizers_config(languages: list[str], registry: dict) -> dict:
+    """Generate recognizers-config.yaml content."""
+    # Build language entries for each recognizer type
+    spacy_langs = [{"language": lang} for lang in languages]
+
+    # Phone recognizer needs context words per language
+    phone_langs = []
+    for lang in languages:
+        lang_config = registry["languages"][lang]
+        entry = {"language": lang}
+        if "phone_context" in lang_config:
+            entry["context"] = lang_config["phone_context"]
+        phone_langs.append(entry)
+
+    return {
+        "supported_languages": languages,
+        "global_regex_flags": 26,
+        "recognizers": [
+            {
+                "name": "SpacyRecognizer",
+                "supported_languages": spacy_langs,
+                "type": "predefined",
+            },
+            {
+                "name": "EmailRecognizer",
+                "supported_languages": spacy_langs,
+                "type": "predefined",
+            },
+            {
+                "name": "PhoneRecognizer",
+                "supported_languages": phone_langs,
+                "type": "predefined",
+            },
+            {
+                "name": "CreditCardRecognizer",
+                "supported_languages": spacy_langs,
+                "type": "predefined",
+            },
+            {
+                "name": "IbanRecognizer",
+                "supported_languages": spacy_langs,
+                "type": "predefined",
+            },
+            {
+                "name": "IpRecognizer",
+                "supported_languages": spacy_langs,
+                "type": "predefined",
+            },
+        ],
+    }
+
+
+def generate_install_script(languages: list[str], registry: dict) -> str:
+    """Generate shell script to install spaCy models."""
+    version = registry["spacy_version"]
+    lines = ["#!/bin/sh", "set -e", ""]
+
+    for lang in languages:
+        model = registry["languages"][lang]["model"]
+        url = f"https://github.com/explosion/spacy-models/releases/download/{model}-{version}/{model}-{version}-py3-none-any.whl"
+        lines.append(f'echo "Installing {model} for {lang}..."')
+        lines.append(f"pip install --no-cache-dir {url}")
+        lines.append("")
+
+    lines.append('echo "All models installed successfully"')
+    return "\n".join(lines)
+
+
+def write_yaml(data: dict, path: Path) -> None:
+    """Write data to YAML file."""
+    with open(path, "w") as f:
+        yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Generate Presidio configs")
+    parser.add_argument(
+        "--languages",
+        required=True,
+        help="Comma-separated list of language codes (e.g., en,de,fr)",
+    )
+    parser.add_argument(
+        "--registry",
+        default="/build/languages.yaml",
+        help="Path to languages.yaml registry",
+    )
+    parser.add_argument(
+        "--output", default="/output", help="Output directory for generated files"
+    )
+    args = parser.parse_args()
+
+    # Parse languages
+    languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
+    if not languages:
+        print("Error: No languages specified", file=sys.stderr)
+        sys.exit(1)
+
+    # Load registry
+    registry_path = Path(args.registry)
+    if not registry_path.exists():
+        print(f"Error: Registry not found: {registry_path}", file=sys.stderr)
+        sys.exit(1)
+
+    registry = load_registry(registry_path)
+
+    # Validate languages
+    languages = validate_languages(languages, registry)
+
+    # Create output directory
+    output_dir = Path(args.output)
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate configs
+    print(f"Generating configs for: {', '.join(languages)}")
+
+    nlp_config = generate_nlp_config(languages, registry)
+    write_yaml(nlp_config, output_dir / "nlp-config.yaml")
+    print(f"  - nlp-config.yaml")
+
+    analyzer_config = generate_analyzer_config(languages)
+    write_yaml(analyzer_config, output_dir / "analyzer-config.yaml")
+    print(f"  - analyzer-config.yaml")
+
+    recognizers_config = generate_recognizers_config(languages, registry)
+    write_yaml(recognizers_config, output_dir / "recognizers-config.yaml")
+    print(f"  - recognizers-config.yaml")
+
+    install_script = generate_install_script(languages, registry)
+    install_path = output_dir / "install-models.sh"
+    with open(install_path, "w") as f:
+        f.write(install_script)
+    install_path.chmod(0o755)
+    print(f"  - install-models.sh")
+
+    print("Done!")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/config.ts b/src/config.ts
new file mode 100644 (file)
index 0000000..65f3578
--- /dev/null
@@ -0,0 +1,219 @@
+import { existsSync, readFileSync } from "node:fs";
+import { parse as parseYaml } from "yaml";
+import { z } from "zod";
+
+// Schema definitions
+
+const LocalProviderSchema = z.object({
+  type: z.enum(["openai", "ollama"]),
+  api_key: z.string().optional(),
+  base_url: z.string().url(),
+  model: z.string(), // Required: maps incoming model to local model
+});
+
+const MaskingSchema = z.object({
+  show_markers: z.boolean().default(false),
+  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 = [
+  "ca", // Catalan
+  "zh", // Chinese
+  "hr", // Croatian
+  "da", // Danish
+  "nl", // Dutch
+  "en", // English
+  "fi", // Finnish
+  "fr", // French
+  "de", // German
+  "el", // Greek
+  "it", // Italian
+  "ja", // Japanese
+  "ko", // Korean
+  "lt", // Lithuanian
+  "mk", // Macedonian
+  "nb", // Norwegian
+  "pl", // Polish
+  "pt", // Portuguese
+  "ro", // Romanian
+  "ru", // Russian
+  "sl", // Slovenian
+  "es", // Spanish
+  "sv", // Swedish
+  "uk", // Ukrainian
+] as const;
+
+const LanguageEnum = z.enum(SupportedLanguages);
+
+const PIIDetectionSchema = z.object({
+  presidio_url: z.string().url(),
+  languages: z.array(LanguageEnum).default(["en"]),
+  fallback_language: LanguageEnum.default("en"),
+  score_threshold: z.number().min(0).max(1).default(0.7),
+  entities: z
+    .array(z.string())
+    .default([
+      "PERSON",
+      "EMAIL_ADDRESS",
+      "PHONE_NUMBER",
+      "CREDIT_CARD",
+      "IBAN_CODE",
+      "IP_ADDRESS",
+      "LOCATION",
+    ]),
+});
+
+const ServerSchema = z.object({
+  port: z.number().default(3000),
+  host: z.string().default("0.0.0.0"),
+});
+
+const LoggingSchema = z.object({
+  database: z.string().default("./data/llm-shield.db"),
+  retention_days: z.number().default(30),
+  log_content: z.boolean().default(false),
+  log_masked_content: z.boolean().default(true),
+});
+
+const DashboardAuthSchema = z.object({
+  username: z.string(),
+  password: z.string(),
+});
+
+const DashboardSchema = z.object({
+  enabled: z.boolean().default(true),
+  auth: DashboardAuthSchema.optional(),
+});
+
+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: z.object({
+      upstream: UpstreamProviderSchema,
+      local: LocalProviderSchema.optional(),
+    }),
+    routing: RoutingSchema.optional(),
+    masking: MaskingSchema.default({}),
+    pii_detection: PIIDetectionSchema,
+    logging: LoggingSchema.default({}),
+    dashboard: DashboardSchema.default({}),
+  })
+  .refine(
+    (config) => {
+      // Route mode requires local provider and routing config
+      if (config.mode === "route") {
+        return config.providers.local !== undefined && config.routing !== undefined;
+      }
+      return true;
+    },
+    {
+      message: "Route mode requires 'providers.local' and 'routing' configuration",
+    },
+  );
+
+export type Config = z.infer<typeof ConfigSchema>;
+export type UpstreamProvider = z.infer<typeof UpstreamProviderSchema>;
+export type LocalProvider = z.infer<typeof LocalProviderSchema>;
+export type MaskingConfig = z.infer<typeof MaskingSchema>;
+
+/**
+ * Replaces ${VAR} and ${VAR:-default} patterns with environment variable values
+ */
+function substituteEnvVars(value: string): string {
+  return value.replace(/\$\{([^}]+)\}/g, (_, expr) => {
+    // Support ${VAR:-default} syntax
+    const [varName, defaultValue] = expr.split(":-");
+    const envValue = process.env[varName];
+    if (envValue) {
+      return envValue;
+    }
+    if (defaultValue !== undefined) {
+      return defaultValue;
+    }
+    console.warn(`Warning: Environment variable ${varName} is not set`);
+    return "";
+  });
+}
+
+/**
+ * Recursively substitutes environment variables in an object
+ */
+function substituteEnvVarsInObject(obj: unknown): unknown {
+  if (typeof obj === "string") {
+    return substituteEnvVars(obj);
+  }
+  if (Array.isArray(obj)) {
+    return obj.map(substituteEnvVarsInObject);
+  }
+  if (obj !== null && typeof obj === "object") {
+    const result: Record<string, unknown> = {};
+    for (const [key, value] of Object.entries(obj)) {
+      result[key] = substituteEnvVarsInObject(value);
+    }
+    return result;
+  }
+  return obj;
+}
+
+/**
+ * Loads configuration from YAML file with environment variable substitution
+ */
+export function loadConfig(configPath?: string): Config {
+  const paths = configPath
+    ? [configPath]
+    : ["./config.yaml", "./config.yml", "./config.example.yaml"];
+
+  let configFile: string | null = null;
+
+  for (const path of paths) {
+    if (existsSync(path)) {
+      configFile = readFileSync(path, "utf-8");
+      break;
+    }
+  }
+
+  if (!configFile) {
+    throw new Error(
+      `No config file found. Tried: ${paths.join(", ")}\nCreate a config.yaml file or copy config.example.yaml`,
+    );
+  }
+
+  const rawConfig = parseYaml(configFile);
+  const configWithEnv = substituteEnvVarsInObject(rawConfig);
+
+  const result = ConfigSchema.safeParse(configWithEnv);
+
+  if (!result.success) {
+    console.error("Config validation errors:");
+    for (const error of result.error.errors) {
+      console.error(`  - ${error.path.join(".")}: ${error.message}`);
+    }
+    throw new Error("Invalid configuration");
+  }
+
+  return result.data;
+}
+
+// Singleton config instance
+let configInstance: Config | null = null;
+
+export function getConfig(): Config {
+  if (!configInstance) {
+    configInstance = loadConfig();
+  }
+  return configInstance;
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644 (file)
index 0000000..4768477
--- /dev/null
@@ -0,0 +1,235 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { createMiddleware } from "hono/factory";
+import { HTTPException } from "hono/http-exception";
+import { logger } from "hono/logger";
+import { getConfig } from "./config";
+import { chatRoutes } from "./routes/chat";
+import { dashboardRoutes } from "./routes/dashboard";
+import { healthRoutes } from "./routes/health";
+import { infoRoutes } from "./routes/info";
+import { getLogger } from "./services/logger";
+import { getPIIDetector } from "./services/pii-detector";
+
+type Variables = {
+  requestId: string;
+};
+
+const config = getConfig();
+const app = new Hono<{ Variables: Variables }>();
+
+// Request ID middleware
+const requestIdMiddleware = createMiddleware<{ Variables: Variables }>(async (c, next) => {
+  const requestId = c.req.header("x-request-id") || crypto.randomUUID();
+  c.set("requestId", requestId);
+  c.header("X-Request-ID", requestId);
+  await next();
+});
+
+// Middleware
+app.use("*", requestIdMiddleware);
+app.use("*", cors());
+app.use("*", logger());
+
+app.route("/", healthRoutes);
+app.route("/", infoRoutes);
+app.route("/openai/v1", chatRoutes);
+
+if (config.dashboard.enabled) {
+  app.route("/dashboard", dashboardRoutes);
+}
+
+app.notFound((c) => {
+  return c.json(
+    {
+      error: {
+        message: `Route not found: ${c.req.method} ${c.req.path}`,
+        type: "not_found",
+      },
+    },
+    404,
+  );
+});
+
+app.onError((err, c) => {
+  if (err instanceof HTTPException) {
+    return c.json(
+      {
+        error: {
+          message: err.message,
+          type: err.status >= 500 ? "server_error" : "client_error",
+        },
+      },
+      err.status,
+    );
+  }
+
+  console.error("Unhandled error:", err);
+  return c.json(
+    {
+      error: {
+        message: "Internal server error",
+        type: "internal_error",
+      },
+    },
+    500,
+  );
+});
+
+const port = config.server.port;
+const host = config.server.host;
+
+export default {
+  port,
+  hostname: host,
+  fetch: app.fetch,
+};
+
+// Startup validation
+validateStartup().then(() => {
+  printStartupBanner(config, host, port);
+  const stopCleanup = startCleanupScheduler(config);
+  setupGracefulShutdown(stopCleanup);
+});
+
+async function validateStartup() {
+  const detector = getPIIDetector();
+
+  // Wait for Presidio to be ready
+  console.log("[STARTUP] Connecting to Presidio...");
+  const ready = await detector.waitForReady(30, 1000);
+
+  if (!ready) {
+    console.error(
+      `[STARTUP] ✗ Could not connect to Presidio at ${config.pii_detection.presidio_url}`,
+    );
+    console.error(
+      "          Make sure Presidio is running: docker compose up presidio-analyzer -d",
+    );
+    process.exit(1);
+  }
+
+  console.log("[STARTUP] ✓ Presidio connected");
+
+  // Validate configured languages
+  console.log(`[STARTUP] Validating languages: ${config.pii_detection.languages.join(", ")}`);
+  const validation = await detector.validateLanguages(config.pii_detection.languages);
+
+  if (validation.missing.length > 0) {
+    console.error("\n❌ Language mismatch detected!\n");
+    console.error(`   Configured: ${config.pii_detection.languages.join(", ")}`);
+    console.error(
+      `   Available:  ${validation.available.length > 0 ? validation.available.join(", ") : "(none)"}`,
+    );
+    console.error(`   Missing:    ${validation.missing.join(", ")}\n`);
+    console.error("   To fix, either:");
+    console.error(
+      `   1. Rebuild: LANGUAGES=${config.pii_detection.languages.join(",")} docker compose build presidio-analyzer`,
+    );
+    console.error(`   2. Update config.yaml languages to: [${validation.available.join(", ")}]\n`);
+    console.error("[STARTUP] ✗ Language configuration mismatch. Exiting for safety.");
+    process.exit(1);
+  } else {
+    console.log("[STARTUP] ✓ All configured languages available");
+  }
+}
+
+function printStartupBanner(config: ReturnType<typeof getConfig>, host: string, port: number) {
+  const modeInfo =
+    config.mode === "route"
+      ? `
+Routing:
+  Default: ${config.routing?.default || "upstream"}
+  On PII:  ${config.routing?.on_pii_detected || "local"}
+
+Providers:
+  Upstream: ${config.providers.upstream.type}
+  Local:    ${config.providers.local?.type || "not configured"} → ${config.providers.local?.model || "n/a"}`
+      : `
+Masking:
+  Markers: ${config.masking.show_markers ? "enabled" : "disabled"}
+
+Provider:
+  Upstream: ${config.providers.upstream.type}`;
+
+  console.log(`
+╔═══════════════════════════════════════════════════════════╗
+║                       LLM-Shield                          ║
+║         Intelligent privacy-aware LLM proxy               ║
+╚═══════════════════════════════════════════════════════════╝
+
+Server:     http://${host}:${port}
+API:        http://${host}:${port}/openai/v1/chat/completions
+Health:     http://${host}:${port}/health
+Info:       http://${host}:${port}/info
+Dashboard:  http://${host}:${port}/dashboard
+
+Mode:       ${config.mode.toUpperCase()}
+${modeInfo}
+
+PII Detection:
+  Languages: ${config.pii_detection.languages.join(", ")}
+  Fallback:  ${config.pii_detection.fallback_language}
+  Threshold: ${config.pii_detection.score_threshold}
+  Entities:  ${config.pii_detection.entities.join(", ")}
+`);
+}
+
+function startCleanupScheduler(config: ReturnType<typeof getConfig>): () => void {
+  let cleanupInterval: ReturnType<typeof setInterval> | null = null;
+
+  if (config.logging.retention_days > 0) {
+    const logger = getLogger();
+
+    // Run cleanup on startup
+    try {
+      const deleted = logger.cleanup();
+      if (deleted > 0) {
+        console.log(
+          `Log cleanup: removed ${deleted} entries older than ${config.logging.retention_days} days`,
+        );
+      }
+    } catch (error) {
+      console.error("Log cleanup failed:", error);
+    }
+
+    // Schedule daily cleanup
+    cleanupInterval = setInterval(
+      () => {
+        try {
+          const count = logger.cleanup();
+          if (count > 0) {
+            console.log(
+              `Log cleanup: removed ${count} entries older than ${config.logging.retention_days} days`,
+            );
+          }
+        } catch (error) {
+          console.error("Log cleanup failed:", error);
+        }
+      },
+      24 * 60 * 60 * 1000,
+    );
+  }
+
+  return () => {
+    if (cleanupInterval) {
+      clearInterval(cleanupInterval);
+    }
+  };
+}
+
+function setupGracefulShutdown(stopCleanup: () => void) {
+  function shutdown() {
+    console.log("\nShutting down...");
+    stopCleanup();
+    try {
+      getLogger().close();
+    } catch {
+      // Logger might not be initialized
+    }
+    process.exit(0);
+  }
+
+  process.on("SIGTERM", shutdown);
+  process.on("SIGINT", shutdown);
+}
diff --git a/src/routes/chat.test.ts b/src/routes/chat.test.ts
new file mode 100644 (file)
index 0000000..99b0e45
--- /dev/null
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { chatRoutes } from "./chat";
+
+const app = new Hono();
+app.route("/openai/v1", chatRoutes);
+
+describe("POST /openai/v1/chat/completions", () => {
+  test("returns 400 for missing messages", async () => {
+    const res = await app.request("/openai/v1/chat/completions", {
+      method: "POST",
+      body: JSON.stringify({}),
+      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 invalid message format", async () => {
+    const res = await app.request("/openai/v1/chat/completions", {
+      method: "POST",
+      body: JSON.stringify({
+        messages: [{ invalid: "format" }],
+      }),
+      headers: { "Content-Type": "application/json" },
+    });
+
+    expect(res.status).toBe(400);
+  });
+
+  test("returns 400 for invalid role", async () => {
+    const res = await app.request("/openai/v1/chat/completions", {
+      method: "POST",
+      body: JSON.stringify({
+        messages: [{ role: "invalid", content: "test" }],
+      }),
+      headers: { "Content-Type": "application/json" },
+    });
+
+    expect(res.status).toBe(400);
+  });
+});
+
+describe("GET /openai/v1/models", () => {
+  test("forwards to upstream (returns error without auth)", async () => {
+    const res = await app.request("/openai/v1/models");
+    // Without auth, upstream returns 401
+    expect([200, 401, 500, 502]).toContain(res.status);
+  });
+});
diff --git a/src/routes/chat.ts b/src/routes/chat.ts
new file mode 100644 (file)
index 0000000..4cfcad6
--- /dev/null
@@ -0,0 +1,232 @@
+import { zValidator } from "@hono/zod-validator";
+import type { Context } from "hono";
+import { Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import { proxy } from "hono/proxy";
+import { z } from "zod";
+import type { MaskingConfig } from "../config";
+import { getRouter, type MaskDecision, type RoutingDecision } from "../services/decision";
+import type {
+  ChatCompletionRequest,
+  ChatCompletionResponse,
+  ChatMessage,
+  LLMResult,
+} from "../services/llm-client";
+import { logRequest, type RequestLogData } from "../services/logger";
+import { unmaskResponse } from "../services/masking";
+import { createUnmaskingStream } from "../services/stream-transformer";
+
+// Request validation schema
+const ChatCompletionSchema = z
+  .object({
+    messages: z
+      .array(
+        z.object({
+          role: z.enum(["system", "user", "assistant"]),
+          content: z.string(),
+        }),
+      )
+      .min(1, "At least one message is required"),
+  })
+  .passthrough();
+
+export const chatRoutes = new Hono();
+
+/**
+ * Type guard for MaskDecision
+ */
+function isMaskDecision(decision: RoutingDecision): decision is MaskDecision {
+  return decision.mode === "mask";
+}
+
+chatRoutes.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
+ */
+chatRoutes.post(
+  "/chat/completions",
+  zValidator("json", ChatCompletionSchema, (result, c) => {
+    if (!result.success) {
+      return c.json(
+        {
+          error: {
+            message: "Invalid request body",
+            type: "invalid_request_error",
+            details: result.error.errors,
+          },
+        },
+        400,
+      );
+    }
+  }),
+  async (c) => {
+    const startTime = Date.now();
+    const body = c.req.valid("json") as ChatCompletionRequest;
+    const router = getRouter();
+
+    let decision: RoutingDecision;
+    try {
+      decision = await router.decide(body.messages);
+    } catch (error) {
+      console.error("PII detection error:", error);
+      throw new HTTPException(503, { message: "PII detection service unavailable" });
+    }
+
+    return handleCompletion(c, body, decision, startTime, router);
+  },
+);
+
+/**
+ * Handle chat completion for both route and mask modes
+ */
+async function handleCompletion(
+  c: Context,
+  body: ChatCompletionRequest,
+  decision: RoutingDecision,
+  startTime: number,
+  router: ReturnType<typeof getRouter>,
+) {
+  const client = router.getClient(decision.provider);
+  const maskingConfig = router.getMaskingConfig();
+  const authHeader = decision.provider === "upstream" ? c.req.header("Authorization") : undefined;
+
+  // Prepare request and masked content for logging
+  let request: ChatCompletionRequest = body;
+  let maskedContent: string | undefined;
+
+  if (isMaskDecision(decision)) {
+    request = { ...body, messages: decision.maskedMessages };
+    maskedContent = formatMessagesForLog(decision.maskedMessages);
+  }
+
+  try {
+    const result = await client.chatCompletion(request, authHeader);
+
+    setShieldHeaders(c, decision);
+
+    if (result.isStreaming) {
+      return handleStreamingResponse(c, result, decision, startTime, maskedContent, maskingConfig);
+    }
+
+    return handleJsonResponse(c, result, decision, startTime, maskedContent, maskingConfig);
+  } catch (error) {
+    console.error("LLM request error:", error);
+    const message = error instanceof Error ? error.message : "Unknown error";
+    throw new HTTPException(502, { message: `LLM provider error: ${message}` });
+  }
+}
+
+/**
+ * Set X-LLM-Shield response headers
+ */
+function setShieldHeaders(c: Context, decision: RoutingDecision) {
+  c.header("X-LLM-Shield-Mode", decision.mode);
+  c.header("X-LLM-Shield-Provider", decision.provider);
+  c.header("X-LLM-Shield-PII-Detected", decision.piiResult.hasPII.toString());
+  c.header("X-LLM-Shield-Language", decision.piiResult.language);
+  if (decision.piiResult.languageFallback) {
+    c.header("X-LLM-Shield-Language-Fallback", "true");
+  }
+  if (decision.mode === "mask") {
+    c.header("X-LLM-Shield-PII-Masked", decision.piiResult.hasPII.toString());
+  }
+}
+
+/**
+ * Handle streaming response
+ */
+function handleStreamingResponse(
+  c: Context,
+  result: LLMResult & { isStreaming: true },
+  decision: RoutingDecision,
+  startTime: number,
+  maskedContent: string | undefined,
+  maskingConfig: MaskingConfig,
+) {
+  logRequest(
+    createLogData(decision, result, startTime, undefined, maskedContent),
+    c.req.header("User-Agent") || null,
+  );
+
+  c.header("Content-Type", "text/event-stream");
+  c.header("Cache-Control", "no-cache");
+  c.header("Connection", "keep-alive");
+
+  if (isMaskDecision(decision)) {
+    const unmaskingStream = createUnmaskingStream(
+      result.response,
+      decision.maskingContext,
+      maskingConfig,
+    );
+    return c.body(unmaskingStream);
+  }
+
+  return c.body(result.response);
+}
+
+/**
+ * Handle JSON response
+ */
+function handleJsonResponse(
+  c: Context,
+  result: LLMResult & { isStreaming: false },
+  decision: RoutingDecision,
+  startTime: number,
+  maskedContent: string | undefined,
+  maskingConfig: MaskingConfig,
+) {
+  logRequest(
+    createLogData(decision, result, startTime, result.response, maskedContent),
+    c.req.header("User-Agent") || null,
+  );
+
+  if (isMaskDecision(decision)) {
+    return c.json(unmaskResponse(result.response, decision.maskingContext, maskingConfig));
+  }
+
+  return c.json(result.response);
+}
+
+/**
+ * Create log data from decision and result
+ */
+function createLogData(
+  decision: RoutingDecision,
+  result: LLMResult,
+  startTime: number,
+  response?: ChatCompletionResponse,
+  maskedContent?: string,
+): RequestLogData {
+  return {
+    timestamp: new Date().toISOString(),
+    mode: decision.mode,
+    provider: decision.provider,
+    model: result.model,
+    piiDetected: decision.piiResult.hasPII,
+    entities: [...new Set(decision.piiResult.newEntities.map((e) => e.entity_type))],
+    latencyMs: Date.now() - startTime,
+    scanTimeMs: decision.piiResult.scanTimeMs,
+    promptTokens: response?.usage?.prompt_tokens,
+    completionTokens: response?.usage?.completion_tokens,
+    language: decision.piiResult.language,
+    languageFallback: decision.piiResult.languageFallback,
+    detectedLanguage: decision.piiResult.detectedLanguage,
+    maskedContent,
+  };
+}
+
+/**
+ * Format messages for logging
+ */
+function formatMessagesForLog(messages: ChatMessage[]): string {
+  return messages.map((m) => `[${m.role}] ${m.content}`).join("\n");
+}
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
new file mode 100644 (file)
index 0000000..d7eccc0
--- /dev/null
@@ -0,0 +1,72 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { basicAuth } from "hono/basic-auth";
+import { tailwind } from "hono-tailwind";
+import { z } from "zod";
+import { getConfig } from "../config";
+import { getLogger } from "../services/logger";
+import DashboardPage from "../views/dashboard/page";
+
+const LogsQuerySchema = z.object({
+       limit: z.coerce.number().min(1).max(1000).default(100),
+       offset: z.coerce.number().min(0).default(0),
+});
+
+const config = getConfig();
+
+export const dashboardRoutes = new Hono();
+
+dashboardRoutes.use("/tailwind.css", tailwind());
+
+if (config.dashboard.auth) {
+       dashboardRoutes.use(
+               "*",
+               basicAuth({
+                       username: config.dashboard.auth.username,
+                       password: config.dashboard.auth.password,
+                       realm: "LLM-Shield Dashboard",
+               }),
+       );
+}
+
+/**
+ * GET /api/logs - Get recent request logs
+ */
+dashboardRoutes.get("/api/logs", zValidator("query", LogsQuerySchema), (c) => {
+       const { limit, offset } = c.req.valid("query");
+
+       const logger = getLogger();
+       const logs = logger.getLogs(limit, offset);
+
+       return c.json({
+               logs,
+               pagination: {
+                       limit,
+                       offset,
+                       count: logs.length,
+               },
+       });
+});
+
+/**
+ * GET /api/stats - Get statistics
+ */
+dashboardRoutes.get("/api/stats", (c) => {
+       const config = getConfig();
+       const logger = getLogger();
+       const stats = logger.getStats();
+       const entityStats = logger.getEntityStats();
+
+       return c.json({
+               ...stats,
+               entity_breakdown: entityStats,
+               mode: config.mode,
+       });
+});
+
+/**
+ * GET /dashboard - Dashboard HTML UI
+ */
+dashboardRoutes.get("/", (c) => {
+       return c.html(<DashboardPage />);
+});
diff --git a/src/routes/health.test.ts b/src/routes/health.test.ts
new file mode 100644 (file)
index 0000000..5bd3c27
--- /dev/null
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { healthRoutes } from "./health";
+
+const app = new Hono();
+app.route("/", healthRoutes);
+
+describe("GET /health", () => {
+  test("returns health status", async () => {
+    const res = await app.request("/health");
+
+    // May be 200 (healthy) or 503 (degraded) depending on Presidio
+    expect([200, 503]).toContain(res.status);
+
+    const body = (await res.json()) as Record<string, unknown>;
+    expect(body.status).toMatch(/healthy|degraded/);
+    expect(body.services).toBeDefined();
+    expect(body.timestamp).toBeDefined();
+  });
+});
diff --git a/src/routes/health.ts b/src/routes/health.ts
new file mode 100644 (file)
index 0000000..3486e6d
--- /dev/null
@@ -0,0 +1,29 @@
+import { Hono } from "hono";
+import { getConfig } from "../config";
+import { getRouter } from "../services/decision";
+
+export const healthRoutes = new Hono();
+
+healthRoutes.get("/health", async (c) => {
+  const config = getConfig();
+  const router = getRouter();
+  const health = await router.healthCheck();
+  const isHealthy = health.presidio;
+
+  const services: Record<string, string> = {
+    presidio: health.presidio ? "up" : "down",
+  };
+
+  if (config.mode === "route") {
+    services.local_llm = health.local ? "up" : "down";
+  }
+
+  return c.json(
+    {
+      status: isHealthy ? "healthy" : "degraded",
+      services,
+      timestamp: new Date().toISOString(),
+    },
+    isHealthy ? 200 : 503,
+  );
+});
diff --git a/src/routes/info.test.ts b/src/routes/info.test.ts
new file mode 100644 (file)
index 0000000..f7ef900
--- /dev/null
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { infoRoutes } from "./info";
+
+const app = new Hono();
+app.route("/", infoRoutes);
+
+describe("GET /info", () => {
+  test("returns 200 with app info", async () => {
+    const res = await app.request("/info");
+
+    expect(res.status).toBe(200);
+
+    const body = (await res.json()) as Record<string, unknown>;
+    expect(body.name).toBe("LLM-Shield");
+    expect(body.version).toBe("0.1.0");
+    expect(body.mode).toBeDefined();
+    expect(body.providers).toBeDefined();
+    expect(body.pii_detection).toBeDefined();
+  });
+
+  test("returns correct content-type", async () => {
+    const res = await app.request("/info");
+
+    expect(res.headers.get("content-type")).toContain("application/json");
+  });
+});
diff --git a/src/routes/info.ts b/src/routes/info.ts
new file mode 100644 (file)
index 0000000..009041d
--- /dev/null
@@ -0,0 +1,59 @@
+import { Hono } from "hono";
+import pkg from "../../package.json";
+import { getConfig } from "../config";
+import { getRouter } from "../services/decision";
+import { getPIIDetector } from "../services/pii-detector";
+
+export const infoRoutes = new Hono();
+
+infoRoutes.get("/info", (c) => {
+  const config = getConfig();
+  const router = getRouter();
+  const providers = router.getProvidersInfo();
+  const detector = getPIIDetector();
+  const languageValidation = detector.getLanguageValidation();
+
+  const info: Record<string, unknown> = {
+    name: "LLM-Shield",
+    version: pkg.version,
+    description: "Intelligent privacy-aware LLM proxy",
+    mode: config.mode,
+    providers: {
+      upstream: {
+        type: providers.upstream.type,
+      },
+    },
+    pii_detection: {
+      languages: languageValidation
+        ? {
+            configured: config.pii_detection.languages,
+            available: languageValidation.available,
+            missing: languageValidation.missing,
+          }
+        : config.pii_detection.languages,
+      fallback_language: config.pii_detection.fallback_language,
+      score_threshold: config.pii_detection.score_threshold,
+      entities: config.pii_detection.entities,
+    },
+  };
+
+  if (config.mode === "route" && config.routing) {
+    info.routing = {
+      default: config.routing.default,
+      on_pii_detected: config.routing.on_pii_detected,
+    };
+    if (providers.local) {
+      (info.providers as Record<string, unknown>).local = {
+        type: providers.local.type,
+      };
+    }
+  }
+
+  if (config.mode === "mask") {
+    info.masking = {
+      show_markers: config.masking.show_markers,
+    };
+  }
+
+  return c.json(info);
+});
diff --git a/src/services/decision.test.ts b/src/services/decision.test.ts
new file mode 100644 (file)
index 0000000..f01236c
--- /dev/null
@@ -0,0 +1,131 @@
+import { describe, expect, test } from "bun:test";
+import type { PIIDetectionResult } from "./pii-detector";
+
+/**
+ * Pure routing logic extracted for testing
+ * This mirrors the logic in Router.decideRoute()
+ */
+function decideRoute(
+  piiResult: PIIDetectionResult,
+  routing: { default: "upstream" | "local"; on_pii_detected: "upstream" | "local" },
+): { provider: "upstream" | "local"; reason: string } {
+  if (piiResult.hasPII) {
+    const entityTypes = [...new Set(piiResult.newEntities.map((e) => e.entity_type))];
+    return {
+      provider: routing.on_pii_detected,
+      reason: `PII detected: ${entityTypes.join(", ")}`,
+    };
+  }
+
+  return {
+    provider: routing.default,
+    reason: "No PII detected",
+  };
+}
+
+/**
+ * Helper to create a mock PIIDetectionResult
+ */
+function createPIIResult(
+  hasPII: boolean,
+  entities: Array<{ entity_type: string }> = [],
+): PIIDetectionResult {
+  const newEntities = entities.map((e) => ({
+    entity_type: e.entity_type,
+    start: 0,
+    end: 10,
+    score: 0.9,
+  }));
+
+  return {
+    hasPII,
+    newEntities,
+    entitiesByMessage: [newEntities],
+    language: "en",
+    languageFallback: false,
+    scanTimeMs: 50,
+  };
+}
+
+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("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);
+    });
+  });
+
+  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,
+      );
+
+      expect(result.provider).toBe("upstream");
+      expect(result.reason).toContain("PII detected");
+    });
+  });
+
+  describe("with same provider for both cases", () => {
+    const routing = { default: "upstream" as const, on_pii_detected: "upstream" as const };
+
+    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");
+    });
+  });
+});
diff --git a/src/services/decision.ts b/src/services/decision.ts
new file mode 100644 (file)
index 0000000..6cc12e4
--- /dev/null
@@ -0,0 +1,187 @@
+import { type Config, getConfig } from "../config";
+import { type ChatMessage, LLMClient } from "../services/llm-client";
+import { createMaskingContext, type MaskingContext, maskMessages } from "../services/masking";
+import { getPIIDetector, type PIIDetectionResult } from "../services/pii-detector";
+
+/**
+ * Routing decision result for route mode
+ */
+export interface RouteDecision {
+  mode: "route";
+  provider: "upstream" | "local";
+  reason: string;
+  piiResult: PIIDetectionResult;
+}
+
+/**
+ * Masking decision result for mask mode
+ */
+export interface MaskDecision {
+  mode: "mask";
+  provider: "upstream";
+  reason: string;
+  piiResult: PIIDetectionResult;
+  maskedMessages: ChatMessage[];
+  maskingContext: MaskingContext;
+}
+
+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)
+ */
+export class Router {
+  private upstreamClient: 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)
+      : null;
+  }
+
+  /**
+   * Returns the current mode
+   */
+  getMode(): "route" | "mask" {
+    return this.config.mode;
+  }
+
+  /**
+   * Decides how to handle messages based on mode and PII detection
+   */
+  async decide(messages: ChatMessage[]): Promise<RoutingDecision> {
+    const detector = getPIIDetector();
+    const piiResult = await detector.analyzeMessages(messages);
+
+    if (this.config.mode === "mask") {
+      return await this.decideMask(messages, piiResult);
+    }
+
+    return this.decideRoute(piiResult);
+  }
+
+  /**
+   * Route mode: decides which provider to use
+   */
+  private decideRoute(piiResult: PIIDetectionResult): RouteDecision {
+    const routing = this.config.routing;
+    if (!routing) {
+      throw new Error("Route mode requires routing configuration");
+    }
+
+    // Route based on PII detection
+    if (piiResult.hasPII) {
+      const entityTypes = [...new Set(piiResult.newEntities.map((e) => e.entity_type))];
+      return {
+        mode: "route",
+        provider: routing.on_pii_detected,
+        reason: `PII detected: ${entityTypes.join(", ")}`,
+        piiResult,
+      };
+    }
+
+    // No PII detected, use default provider
+    return {
+      mode: "route",
+      provider: routing.default,
+      reason: "No PII detected",
+      piiResult,
+    };
+  }
+
+  private async decideMask(
+    messages: ChatMessage[],
+    piiResult: PIIDetectionResult,
+  ): Promise<MaskDecision> {
+    if (!piiResult.hasPII) {
+      return {
+        mode: "mask",
+        provider: "upstream",
+        reason: "No PII detected",
+        piiResult,
+        maskedMessages: messages,
+        maskingContext: createMaskingContext(),
+      };
+    }
+
+    const detector = getPIIDetector();
+    const fullScan = await detector.analyzeAllMessages(messages, {
+      language: piiResult.language,
+      usedFallback: piiResult.languageFallback,
+    });
+
+    const { masked, context } = maskMessages(messages, fullScan.entitiesByMessage);
+
+    const entityTypes = [...new Set(piiResult.newEntities.map((e) => e.entity_type))];
+
+    return {
+      mode: "mask",
+      provider: "upstream",
+      reason: `PII masked: ${entityTypes.join(", ")}`,
+      piiResult,
+      maskedMessages: masked,
+      maskingContext: context,
+    };
+  }
+
+  getClient(provider: "upstream" | "local"): LLMClient {
+    if (provider === "local") {
+      if (!this.localClient) {
+        throw new Error("Local provider not configured");
+      }
+      return this.localClient;
+    }
+    return this.upstreamClient;
+  }
+
+  /**
+   * Gets masking config
+   */
+  getMaskingConfig() {
+    return this.config.masking;
+  }
+
+  /**
+   * Checks health of services (Presidio required, local LLM only in route mode)
+   */
+  async healthCheck(): Promise<{
+    local: boolean;
+    presidio: boolean;
+  }> {
+    const detector = getPIIDetector();
+
+    const [presidioHealth, localHealth] = await Promise.all([
+      detector.healthCheck(),
+      this.localClient?.healthCheck() ?? Promise.resolve(true),
+    ]);
+
+    return {
+      local: localHealth,
+      presidio: presidioHealth,
+    };
+  }
+
+  getProvidersInfo() {
+    return {
+      mode: this.config.mode,
+      upstream: this.upstreamClient.getInfo(),
+      local: this.localClient?.getInfo() ?? null,
+    };
+  }
+}
+
+// Singleton instance
+let routerInstance: Router | null = null;
+
+export function getRouter(): Router {
+  if (!routerInstance) {
+    routerInstance = new Router();
+  }
+  return routerInstance;
+}
diff --git a/src/services/language-detector.ts b/src/services/language-detector.ts
new file mode 100644 (file)
index 0000000..5991538
--- /dev/null
@@ -0,0 +1,94 @@
+import eld from "eld/small";
+import { getConfig } from "../config";
+
+// All 24 spaCy languages with trained pipelines
+export type SupportedLanguage =
+  | "ca"
+  | "zh"
+  | "hr"
+  | "da"
+  | "nl"
+  | "en"
+  | "fi"
+  | "fr"
+  | "de"
+  | "el"
+  | "it"
+  | "ja"
+  | "ko"
+  | "lt"
+  | "mk"
+  | "nb"
+  | "pl"
+  | "pt"
+  | "ro"
+  | "ru"
+  | "sl"
+  | "es"
+  | "sv"
+  | "uk";
+
+export interface LanguageDetectionResult {
+  language: SupportedLanguage;
+  usedFallback: boolean;
+  detectedLanguage?: string;
+  confidence?: number;
+}
+
+// Special case mapping: Norwegian detected as "no" but Presidio expects "nb"
+const ISO_TO_PRESIDIO_OVERRIDES: Record<string, SupportedLanguage> = {
+  no: "nb", // Norwegian (generic) → Norwegian Bokmål
+};
+
+export class LanguageDetector {
+  private configuredLanguages: SupportedLanguage[];
+  private fallbackLanguage: SupportedLanguage;
+
+  constructor() {
+    const config = getConfig();
+    this.configuredLanguages = config.pii_detection.languages;
+    this.fallbackLanguage = config.pii_detection.fallback_language;
+  }
+
+  detect(text: string): LanguageDetectionResult {
+    if (this.configuredLanguages.length === 1) {
+      return {
+        language: this.configuredLanguages[0],
+        usedFallback: false,
+      };
+    }
+
+    const result = eld.detect(text);
+    const detectedIso = result.language;
+    const scores = result.getScores();
+    const confidence = scores[detectedIso] ?? 0;
+    // Use override if exists, otherwise use the detected code as-is (most are 1:1)
+    const presidioLang = (ISO_TO_PRESIDIO_OVERRIDES[detectedIso] ||
+      detectedIso) as SupportedLanguage;
+
+    if (presidioLang && this.configuredLanguages.includes(presidioLang)) {
+      return {
+        language: presidioLang,
+        usedFallback: false,
+        detectedLanguage: detectedIso,
+        confidence,
+      };
+    }
+
+    return {
+      language: this.fallbackLanguage,
+      usedFallback: true,
+      detectedLanguage: detectedIso,
+      confidence,
+    };
+  }
+}
+
+let detectorInstance: LanguageDetector | null = null;
+
+export function getLanguageDetector(): LanguageDetector {
+  if (!detectorInstance) {
+    detectorInstance = new LanguageDetector();
+  }
+  return detectorInstance;
+}
diff --git a/src/services/llm-client.ts b/src/services/llm-client.ts
new file mode 100644 (file)
index 0000000..8745899
--- /dev/null
@@ -0,0 +1,182 @@
+import type { LocalProvider, UpstreamProvider } from "../config";
+
+/**
+ * OpenAI-compatible message format
+ */
+export interface ChatMessage {
+  role: "system" | "user" | "assistant";
+  content: string;
+}
+
+/**
+ * OpenAI-compatible chat completion request
+ * Only required field is messages - all other params pass through to provider
+ */
+export interface ChatCompletionRequest {
+  messages: ChatMessage[];
+  model?: string;
+  stream?: boolean;
+  [key: string]: unknown;
+}
+
+/**
+ * OpenAI-compatible chat completion response
+ */
+export interface ChatCompletionResponse {
+  id: string;
+  object: "chat.completion";
+  created: number;
+  model: string;
+  choices: Array<{
+    index: number;
+    message: ChatMessage;
+    finish_reason: "stop" | "length" | "content_filter" | null;
+  }>;
+  usage?: {
+    prompt_tokens: number;
+    completion_tokens: number;
+    total_tokens: number;
+  };
+}
+
+/**
+ * Result from LLM client including metadata (Discriminated Union)
+ */
+export type LLMResult =
+  | {
+      isStreaming: true;
+      response: ReadableStream<Uint8Array>;
+      model: string;
+      provider: "upstream" | "local";
+    }
+  | {
+      isStreaming: false;
+      response: ChatCompletionResponse;
+      model: string;
+      provider: "upstream" | "local";
+    };
+
+/**
+ * LLM Client for OpenAI-compatible APIs (OpenAI, Ollama, etc.)
+ */
+export class LLMClient {
+  private baseUrl: string;
+  private apiKey?: string;
+  private providerType: "openai" | "ollama";
+  private providerName: "upstream" | "local";
+  private defaultModel?: string;
+
+  constructor(
+    provider: UpstreamProvider | LocalProvider,
+    providerName: "upstream" | "local",
+    defaultModel?: string,
+  ) {
+    this.baseUrl = provider.base_url.replace(/\/$/, "");
+    this.apiKey = provider.api_key;
+    this.providerType = provider.type;
+    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)
+   */
+  async chatCompletion(request: ChatCompletionRequest, authHeader?: string): Promise<LLMResult> {
+    // Local uses configured model, upstream uses request model
+    const model = this.defaultModel || request.model;
+    const isStreaming = request.stream ?? false;
+
+    if (!model) {
+      throw new Error("Model is required in request");
+    }
+
+    // Build the endpoint URL
+    const endpoint =
+      this.providerType === "ollama"
+        ? `${this.baseUrl}/v1/chat/completions`
+        : `${this.baseUrl}/chat/completions`;
+
+    // Build headers
+    const headers: Record<string, string> = {
+      "Content-Type": "application/json",
+    };
+
+    // Use client's auth header if provided, otherwise fall back to config
+    if (authHeader) {
+      headers.Authorization = authHeader;
+    } else if (this.apiKey) {
+      headers.Authorization = `Bearer ${this.apiKey}`;
+    }
+
+    // Build request body - convert max_tokens to max_completion_tokens for OpenAI
+    const body: Record<string, unknown> = {
+      ...request,
+      model,
+      stream: isStreaming,
+    };
+
+    // OpenAI newer models use max_completion_tokens instead of max_tokens
+    if (this.providerType === "openai" && body.max_tokens) {
+      body.max_completion_tokens = body.max_tokens;
+      delete body.max_tokens;
+    }
+
+    const response = await fetch(endpoint, {
+      method: "POST",
+      headers,
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(120_000), // 2 minute timeout for LLM requests
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(`LLM API error: ${response.status} ${response.statusText} - ${errorText}`);
+    }
+
+    if (isStreaming) {
+      if (!response.body) {
+        throw new Error("No response body for streaming request");
+      }
+
+      return {
+        response: response.body,
+        isStreaming: true,
+        model,
+        provider: this.providerName,
+      };
+    }
+
+    const data = (await response.json()) as ChatCompletionResponse;
+    return {
+      response: data,
+      isStreaming: false,
+      model,
+      provider: this.providerName,
+    };
+  }
+
+  /**
+   * Checks if the local LLM service is healthy (Ollama)
+   */
+  async healthCheck(): Promise<boolean> {
+    try {
+      const response = await fetch(`${this.baseUrl}/api/tags`, {
+        method: "GET",
+        signal: AbortSignal.timeout(5000),
+      });
+      return response.ok;
+    } catch {
+      return false;
+    }
+  }
+
+  getInfo(): { name: "upstream" | "local"; type: "openai" | "ollama"; baseUrl: string } {
+    return {
+      name: this.providerName,
+      type: this.providerType,
+      baseUrl: this.baseUrl,
+    };
+  }
+}
diff --git a/src/services/logger.ts b/src/services/logger.ts
new file mode 100644 (file)
index 0000000..b79ad8b
--- /dev/null
@@ -0,0 +1,299 @@
+import { Database } from "bun:sqlite";
+import { mkdirSync } from "node:fs";
+import { getConfig } from "../config";
+
+export interface RequestLog {
+  id?: number;
+  timestamp: string;
+  mode: "route" | "mask";
+  provider: "upstream" | "local";
+  model: string;
+  pii_detected: boolean;
+  entities: string;
+  latency_ms: number;
+  scan_time_ms: number;
+  prompt_tokens: number | null;
+  completion_tokens: number | null;
+  user_agent: string | null;
+  language: string;
+  language_fallback: boolean;
+  detected_language: string | null;
+  masked_content: string | null;
+}
+
+/**
+ * Statistics summary
+ */
+export interface Stats {
+  total_requests: number;
+  pii_requests: number;
+  pii_percentage: number;
+  upstream_requests: number;
+  local_requests: number;
+  avg_scan_time_ms: number;
+  total_tokens: number;
+  requests_last_hour: number;
+}
+
+/**
+ * SQLite-based logger for request tracking
+ */
+export class Logger {
+  private db: Database;
+  private retentionDays: number;
+
+  constructor() {
+    const config = getConfig();
+    this.retentionDays = config.logging.retention_days;
+
+    // Ensure data directory exists
+    const dbPath = config.logging.database;
+    const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
+    if (dir) {
+      mkdirSync(dir, { recursive: true });
+    }
+
+    this.db = new Database(dbPath);
+    this.initializeDatabase();
+  }
+
+  private initializeDatabase(): void {
+    this.db.run(`
+      CREATE TABLE IF NOT EXISTS request_logs (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        timestamp TEXT NOT NULL,
+        mode TEXT NOT NULL DEFAULT 'route',
+        provider TEXT NOT NULL,
+        model TEXT NOT NULL,
+        pii_detected INTEGER NOT NULL DEFAULT 0,
+        entities TEXT,
+        latency_ms INTEGER NOT NULL,
+        scan_time_ms INTEGER NOT NULL DEFAULT 0,
+        prompt_tokens INTEGER,
+        completion_tokens INTEGER,
+        user_agent TEXT,
+        language TEXT NOT NULL DEFAULT 'en',
+        language_fallback INTEGER NOT NULL DEFAULT 0,
+        detected_language TEXT,
+        masked_content TEXT,
+        created_at TEXT DEFAULT CURRENT_TIMESTAMP
+      )
+    `);
+
+    // Create indexes for performance
+    this.db.run(`
+      CREATE INDEX IF NOT EXISTS idx_timestamp ON request_logs(timestamp)
+    `);
+    this.db.run(`
+      CREATE INDEX IF NOT EXISTS idx_provider ON request_logs(provider)
+    `);
+    this.db.run(`
+      CREATE INDEX IF NOT EXISTS idx_pii_detected ON request_logs(pii_detected)
+    `);
+  }
+
+  log(entry: Omit<RequestLog, "id">): void {
+    const stmt = this.db.prepare(`
+      INSERT INTO request_logs
+        (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content)
+      VALUES
+        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    `);
+
+    stmt.run(
+      entry.timestamp,
+      entry.mode,
+      entry.provider,
+      entry.model,
+      entry.pii_detected ? 1 : 0,
+      entry.entities,
+      entry.latency_ms,
+      entry.scan_time_ms,
+      entry.prompt_tokens,
+      entry.completion_tokens,
+      entry.user_agent,
+      entry.language,
+      entry.language_fallback ? 1 : 0,
+      entry.detected_language,
+      entry.masked_content,
+    );
+  }
+
+  /**
+   * Gets recent logs
+   */
+  getLogs(limit: number = 100, offset: number = 0): RequestLog[] {
+    const stmt = this.db.prepare(`
+      SELECT * FROM request_logs
+      ORDER BY timestamp DESC
+      LIMIT ? OFFSET ?
+    `);
+
+    return stmt.all(limit, offset) as RequestLog[];
+  }
+
+  /**
+   * Gets statistics
+   */
+  getStats(): Stats {
+    // Total requests
+    const totalResult = this.db.prepare(`SELECT COUNT(*) as count FROM request_logs`).get() as {
+      count: number;
+    };
+
+    // PII requests
+    const piiResult = this.db
+      .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE pii_detected = 1`)
+      .get() as { count: number };
+
+    // Upstream vs Local
+    const upstreamResult = this.db
+      .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'upstream'`)
+      .get() as { count: number };
+    const localResult = this.db
+      .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'local'`)
+      .get() as { count: number };
+
+    // Average scan time
+    const scanTimeResult = this.db
+      .prepare(`SELECT AVG(scan_time_ms) as avg FROM request_logs`)
+      .get() as { avg: number | null };
+
+    // Total tokens
+    const tokensResult = this.db
+      .prepare(`
+      SELECT COALESCE(SUM(COALESCE(prompt_tokens, 0) + COALESCE(completion_tokens, 0)), 0) as total
+      FROM request_logs
+    `)
+      .get() as { total: number };
+
+    // Requests last hour
+    const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+    const hourResult = this.db
+      .prepare(`
+      SELECT COUNT(*) as count FROM request_logs
+      WHERE timestamp >= ?
+    `)
+      .get(oneHourAgo) as { count: number };
+
+    const total = totalResult.count;
+    const pii = piiResult.count;
+
+    return {
+      total_requests: total,
+      pii_requests: pii,
+      pii_percentage: total > 0 ? Math.round((pii / total) * 100 * 10) / 10 : 0,
+      upstream_requests: upstreamResult.count,
+      local_requests: localResult.count,
+      avg_scan_time_ms: Math.round(scanTimeResult.avg || 0),
+      total_tokens: tokensResult.total,
+      requests_last_hour: hourResult.count,
+    };
+  }
+
+  /**
+   * Gets entity breakdown
+   */
+  getEntityStats(): Array<{ entity: string; count: number }> {
+    const logs = this.db
+      .prepare(`
+      SELECT entities FROM request_logs WHERE entities IS NOT NULL AND entities != ''
+    `)
+      .all() as Array<{ entities: string }>;
+
+    const entityCounts = new Map<string, number>();
+
+    for (const log of logs) {
+      const entities = log.entities
+        .split(",")
+        .map((e) => e.trim())
+        .filter(Boolean);
+      for (const entity of entities) {
+        entityCounts.set(entity, (entityCounts.get(entity) || 0) + 1);
+      }
+    }
+
+    return Array.from(entityCounts.entries())
+      .map(([entity, count]) => ({ entity, count }))
+      .sort((a, b) => b.count - a.count);
+  }
+
+  /**
+   * Cleans up old logs based on retention policy
+   */
+  cleanup(): number {
+    if (this.retentionDays <= 0) {
+      return 0; // Keep forever
+    }
+
+    const cutoffDate = new Date();
+    cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays);
+
+    const result = this.db
+      .prepare(`
+      DELETE FROM request_logs WHERE timestamp < ?
+    `)
+      .run(cutoffDate.toISOString());
+
+    return result.changes;
+  }
+
+  /**
+   * Closes database connection
+   */
+  close(): void {
+    this.db.close();
+  }
+}
+
+// Singleton instance
+let loggerInstance: Logger | null = null;
+
+export function getLogger(): Logger {
+  if (!loggerInstance) {
+    loggerInstance = new Logger();
+  }
+  return loggerInstance;
+}
+
+export interface RequestLogData {
+  timestamp: string;
+  mode: "route" | "mask";
+  provider: "upstream" | "local";
+  model: string;
+  piiDetected: boolean;
+  entities: string[];
+  latencyMs: number;
+  scanTimeMs: number;
+  promptTokens?: number;
+  completionTokens?: number;
+  language: string;
+  languageFallback: boolean;
+  detectedLanguage?: string;
+  maskedContent?: string;
+}
+
+export function logRequest(data: RequestLogData, userAgent: string | null): void {
+  try {
+    const logger = getLogger();
+    logger.log({
+      timestamp: data.timestamp,
+      mode: data.mode,
+      provider: data.provider,
+      model: data.model,
+      pii_detected: data.piiDetected,
+      entities: data.entities.join(","),
+      latency_ms: data.latencyMs,
+      scan_time_ms: data.scanTimeMs,
+      prompt_tokens: data.promptTokens ?? null,
+      completion_tokens: data.completionTokens ?? null,
+      user_agent: userAgent,
+      language: data.language,
+      language_fallback: data.languageFallback,
+      detected_language: data.detectedLanguage ?? null,
+      masked_content: data.maskedContent ?? null,
+    });
+  } catch (error) {
+    console.error("Failed to log request:", error);
+  }
+}
diff --git a/src/services/masking.test.ts b/src/services/masking.test.ts
new file mode 100644 (file)
index 0000000..590b509
--- /dev/null
@@ -0,0 +1,433 @@
+import { describe, expect, test } from "bun:test";
+import type { MaskingConfig } from "../config";
+import type { ChatMessage } from "./llm-client";
+import {
+  createMaskingContext,
+  flushStreamBuffer,
+  mask,
+  maskMessages,
+  unmask,
+  unmaskResponse,
+  unmaskStreamChunk,
+} from "./masking";
+import type { PIIEntity } from "./pii-detector";
+
+const defaultConfig: MaskingConfig = {
+  show_markers: false,
+  marker_text: "[protected]",
+};
+
+const configWithMarkers: MaskingConfig = {
+  show_markers: true,
+  marker_text: "[protected]",
+};
+
+describe("mask", () => {
+  test("returns original text when no entities", () => {
+    const result = mask("Hello world", []);
+    expect(result.masked).toBe("Hello world");
+    expect(Object.keys(result.context.mapping)).toHaveLength(0);
+  });
+
+  test("masks single email entity", () => {
+    // "Contact: john@example.com please"
+    //           ^9             ^25
+    const entities: PIIEntity[] = [{ entity_type: "EMAIL_ADDRESS", start: 9, end: 25, score: 1.0 }];
+
+    const result = mask("Contact: john@example.com please", entities);
+
+    expect(result.masked).toBe("Contact: <EMAIL_ADDRESS_1> please");
+    expect(result.context.mapping["<EMAIL_ADDRESS_1>"]).toBe("john@example.com");
+  });
+
+  test("masks multiple entities of same type", () => {
+    const text = "Emails: a@b.com and c@d.com";
+    const entities: PIIEntity[] = [
+      { entity_type: "EMAIL_ADDRESS", start: 8, end: 15, score: 1.0 },
+      { entity_type: "EMAIL_ADDRESS", start: 20, end: 27, score: 1.0 },
+    ];
+
+    const result = mask(text, entities);
+
+    expect(result.masked).toBe("Emails: <EMAIL_ADDRESS_1> and <EMAIL_ADDRESS_2>");
+    expect(result.context.mapping["<EMAIL_ADDRESS_1>"]).toBe("a@b.com");
+    expect(result.context.mapping["<EMAIL_ADDRESS_2>"]).toBe("c@d.com");
+  });
+
+  test("masks multiple entity types", () => {
+    const text = "Hans Müller: hans@firma.de";
+    const entities: PIIEntity[] = [
+      { entity_type: "PERSON", start: 0, end: 11, score: 0.9 },
+      { entity_type: "EMAIL_ADDRESS", start: 13, end: 26, score: 1.0 },
+    ];
+
+    const result = mask(text, entities);
+
+    expect(result.masked).toBe("<PERSON_1>: <EMAIL_ADDRESS_1>");
+    expect(result.context.mapping["<PERSON_1>"]).toBe("Hans Müller");
+    expect(result.context.mapping["<EMAIL_ADDRESS_1>"]).toBe("hans@firma.de");
+  });
+
+  test("reuses placeholder for duplicate values", () => {
+    const text = "a@b.com and again a@b.com";
+    const entities: PIIEntity[] = [
+      { entity_type: "EMAIL_ADDRESS", start: 0, end: 7, score: 1.0 },
+      { entity_type: "EMAIL_ADDRESS", start: 18, end: 25, score: 1.0 },
+    ];
+
+    const result = mask(text, entities);
+
+    // Same value should get same placeholder
+    expect(result.masked).toBe("<EMAIL_ADDRESS_1> and again <EMAIL_ADDRESS_1>");
+    expect(Object.keys(result.context.mapping)).toHaveLength(1);
+  });
+
+  test("handles adjacent entities", () => {
+    const text = "HansMüller";
+    const entities: PIIEntity[] = [
+      { entity_type: "PERSON", start: 0, end: 4, score: 0.9 },
+      { entity_type: "PERSON", start: 4, end: 10, score: 0.9 },
+    ];
+
+    const result = mask(text, entities);
+
+    expect(result.masked).toBe("<PERSON_1><PERSON_2>");
+  });
+
+  test("preserves context across calls", () => {
+    const context = createMaskingContext();
+
+    const result1 = mask(
+      "Email: a@b.com",
+      [{ entity_type: "EMAIL_ADDRESS", start: 7, end: 14, score: 1.0 }],
+      context,
+    );
+
+    expect(result1.masked).toBe("Email: <EMAIL_ADDRESS_1>");
+
+    const result2 = mask(
+      "Another: c@d.com",
+      [{ entity_type: "EMAIL_ADDRESS", start: 9, end: 16, score: 1.0 }],
+      context,
+    );
+
+    // Should continue numbering
+    expect(result2.masked).toBe("Another: <EMAIL_ADDRESS_2>");
+    expect(context.mapping["<EMAIL_ADDRESS_1>"]).toBe("a@b.com");
+    expect(context.mapping["<EMAIL_ADDRESS_2>"]).toBe("c@d.com");
+  });
+});
+
+describe("unmask", () => {
+  test("returns original text when no mappings", () => {
+    const context = createMaskingContext();
+    const result = unmask("Hello world", context, defaultConfig);
+    expect(result).toBe("Hello world");
+  });
+
+  test("restores single placeholder", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "john@example.com";
+
+    const result = unmask("Reply to <EMAIL_ADDRESS_1>", context, defaultConfig);
+    expect(result).toBe("Reply to john@example.com");
+  });
+
+  test("restores multiple placeholders", () => {
+    const context = createMaskingContext();
+    context.mapping["<PERSON_1>"] = "Hans Müller";
+    context.mapping["<EMAIL_ADDRESS_1>"] = "hans@firma.de";
+
+    const result = unmask(
+      "Hello <PERSON_1>, your email <EMAIL_ADDRESS_1> is confirmed",
+      context,
+      defaultConfig,
+    );
+    expect(result).toBe("Hello Hans Müller, your email hans@firma.de is confirmed");
+  });
+
+  test("restores repeated placeholders", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const result = unmask("<EMAIL_ADDRESS_1> and <EMAIL_ADDRESS_1>", context, defaultConfig);
+    expect(result).toBe("test@test.com and test@test.com");
+  });
+
+  test("adds markers when configured", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "john@example.com";
+
+    const result = unmask("Email: <EMAIL_ADDRESS_1>", context, configWithMarkers);
+    expect(result).toBe("Email: [protected]john@example.com");
+  });
+
+  test("handles partial placeholder (no match)", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const result = unmask("Text with <EMAIL_ADDRESS_2>", context, defaultConfig);
+    expect(result).toBe("Text with <EMAIL_ADDRESS_2>"); // No match, unchanged
+  });
+});
+
+describe("mask -> unmask roundtrip", () => {
+  test("preserves original data through roundtrip", () => {
+    const originalText = "Contact Hans Müller at hans@firma.de or call +49123456789";
+    const entities: PIIEntity[] = [
+      { entity_type: "PERSON", start: 8, end: 19, score: 0.9 },
+      { entity_type: "EMAIL_ADDRESS", start: 23, end: 36, score: 1.0 },
+      { entity_type: "PHONE_NUMBER", start: 45, end: 57, score: 0.95 },
+    ];
+
+    const { masked, context } = mask(originalText, entities);
+
+    // Verify masking worked
+    expect(masked).not.toContain("Hans Müller");
+    expect(masked).not.toContain("hans@firma.de");
+    expect(masked).not.toContain("+49123456789");
+
+    // Simulate LLM response that echoes placeholders
+    const llmResponse = `I see your contact info: ${masked.match(/<PERSON_1>/)?.[0]}, email ${masked.match(/<EMAIL_ADDRESS_1>/)?.[0]}`;
+
+    const unmasked = unmask(llmResponse, context, defaultConfig);
+
+    expect(unmasked).toContain("Hans Müller");
+    expect(unmasked).toContain("hans@firma.de");
+  });
+
+  test("handles empty entities array", () => {
+    const text = "No PII here";
+    const { masked, context } = mask(text, []);
+    const unmasked = unmask(masked, context, defaultConfig);
+
+    expect(unmasked).toBe(text);
+  });
+});
+
+describe("maskMessages", () => {
+  test("masks multiple messages", () => {
+    const messages: ChatMessage[] = [
+      { role: "user", content: "My email is test@example.com" },
+      { role: "assistant", content: "Got it" },
+      { role: "user", content: "Also john@test.com" },
+    ];
+
+    const entitiesByMessage: PIIEntity[][] = [
+      [{ entity_type: "EMAIL_ADDRESS", start: 12, end: 28, score: 1.0 }],
+      [],
+      [{ entity_type: "EMAIL_ADDRESS", start: 5, end: 18, score: 1.0 }],
+    ];
+
+    const { masked, context } = maskMessages(messages, entitiesByMessage);
+
+    expect(masked[0].content).toBe("My email is <EMAIL_ADDRESS_1>");
+    expect(masked[1].content).toBe("Got it");
+    expect(masked[2].content).toBe("Also <EMAIL_ADDRESS_2>");
+
+    expect(context.mapping["<EMAIL_ADDRESS_1>"]).toBe("test@example.com");
+    expect(context.mapping["<EMAIL_ADDRESS_2>"]).toBe("john@test.com");
+  });
+
+  test("preserves message roles", () => {
+    const messages: ChatMessage[] = [
+      { role: "system", content: "You are helpful" },
+      { role: "user", content: "Hi" },
+    ];
+
+    const { masked } = maskMessages(messages, [[], []]);
+
+    expect(masked[0].role).toBe("system");
+    expect(masked[1].role).toBe("user");
+  });
+});
+
+describe("streaming unmask", () => {
+  test("unmasks complete placeholder in chunk", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const { output, remainingBuffer } = unmaskStreamChunk(
+      "",
+      "Hello <EMAIL_ADDRESS_1>!",
+      context,
+      defaultConfig,
+    );
+
+    expect(output).toBe("Hello test@test.com!");
+    expect(remainingBuffer).toBe("");
+  });
+
+  test("buffers partial placeholder", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const { output, remainingBuffer } = unmaskStreamChunk(
+      "",
+      "Hello <EMAIL_ADD",
+      context,
+      defaultConfig,
+    );
+
+    expect(output).toBe("Hello ");
+    expect(remainingBuffer).toBe("<EMAIL_ADD");
+  });
+
+  test("completes buffered placeholder", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const { output, remainingBuffer } = unmaskStreamChunk(
+      "<EMAIL_ADD",
+      "RESS_1> there",
+      context,
+      defaultConfig,
+    );
+
+    expect(output).toBe("test@test.com there");
+    expect(remainingBuffer).toBe("");
+  });
+
+  test("handles text without placeholders", () => {
+    const context = createMaskingContext();
+
+    const { output, remainingBuffer } = unmaskStreamChunk(
+      "",
+      "Just normal text",
+      context,
+      defaultConfig,
+    );
+
+    expect(output).toBe("Just normal text");
+    expect(remainingBuffer).toBe("");
+  });
+
+  test("flushes remaining buffer", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    // Partial that never completes
+    const flushed = flushStreamBuffer("<EMAIL_ADD", context, defaultConfig);
+
+    // Should return as-is since no complete placeholder
+    expect(flushed).toBe("<EMAIL_ADD");
+  });
+});
+
+describe("unmaskResponse", () => {
+  test("unmasks all choices in response", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+    context.mapping["<PERSON_1>"] = "John Doe";
+
+    const response = {
+      id: "chatcmpl-123",
+      object: "chat.completion" as const,
+      created: 1234567890,
+      model: "gpt-4",
+      choices: [
+        {
+          index: 0,
+          message: {
+            role: "assistant" as const,
+            content: "Contact <PERSON_1> at <EMAIL_ADDRESS_1>",
+          },
+          finish_reason: "stop" as const,
+        },
+      ],
+      usage: {
+        prompt_tokens: 10,
+        completion_tokens: 20,
+        total_tokens: 30,
+      },
+    };
+
+    const result = unmaskResponse(response, context, defaultConfig);
+
+    expect(result.choices[0].message.content).toBe("Contact John Doe at test@test.com");
+    expect(result.id).toBe("chatcmpl-123");
+    expect(result.model).toBe("gpt-4");
+  });
+
+  test("handles multiple choices", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "a@b.com";
+
+    const response = {
+      id: "chatcmpl-456",
+      object: "chat.completion" as const,
+      created: 1234567890,
+      model: "gpt-4",
+      choices: [
+        {
+          index: 0,
+          message: { role: "assistant" as const, content: "First: <EMAIL_ADDRESS_1>" },
+          finish_reason: "stop" as const,
+        },
+        {
+          index: 1,
+          message: { role: "assistant" as const, content: "Second: <EMAIL_ADDRESS_1>" },
+          finish_reason: "stop" as const,
+        },
+      ],
+    };
+
+    const result = unmaskResponse(response, context, defaultConfig);
+
+    expect(result.choices[0].message.content).toBe("First: a@b.com");
+    expect(result.choices[1].message.content).toBe("Second: a@b.com");
+  });
+
+  test("preserves response structure", () => {
+    const context = createMaskingContext();
+    const response = {
+      id: "test-id",
+      object: "chat.completion" as const,
+      created: 999,
+      model: "test-model",
+      choices: [
+        {
+          index: 0,
+          message: { role: "assistant" as const, content: "No placeholders" },
+          finish_reason: null,
+        },
+      ],
+      usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
+    };
+
+    const result = unmaskResponse(response, context, defaultConfig);
+
+    expect(result.id).toBe("test-id");
+    expect(result.object).toBe("chat.completion");
+    expect(result.created).toBe(999);
+    expect(result.model).toBe("test-model");
+    expect(result.usage).toEqual({ prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 });
+  });
+});
+
+describe("edge cases", () => {
+  test("handles unicode in masked text", () => {
+    const text = "Kontakt: François Müller";
+    const entities: PIIEntity[] = [{ entity_type: "PERSON", start: 9, end: 24, score: 0.9 }];
+
+    const { masked, context } = mask(text, entities);
+    expect(masked).toBe("Kontakt: <PERSON_1>");
+
+    const unmasked = unmask(masked, context, defaultConfig);
+    expect(unmasked).toBe("Kontakt: François Müller");
+  });
+
+  test("handles empty text", () => {
+    const { masked, context } = mask("", []);
+    expect(masked).toBe("");
+    expect(unmask("", context, defaultConfig)).toBe("");
+  });
+
+  test("handles placeholder-like text that is not a real placeholder", () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const result = unmask("Use <UNKNOWN_1> format", context, defaultConfig);
+    expect(result).toBe("Use <UNKNOWN_1> format");
+  });
+});
diff --git a/src/services/masking.ts b/src/services/masking.ts
new file mode 100644 (file)
index 0000000..fa5da21
--- /dev/null
@@ -0,0 +1,204 @@
+import type { MaskingConfig } from "../config";
+import type { ChatCompletionResponse, ChatMessage } from "./llm-client";
+import type { PIIEntity } from "./pii-detector";
+
+export interface MaskingContext {
+  mapping: Record<string, string>;
+  reverseMapping: Record<string, string>;
+  counters: Record<string, number>;
+}
+
+export interface MaskResult {
+  masked: string;
+  context: MaskingContext;
+}
+
+/**
+ * Creates a new masking context for a request
+ */
+export function createMaskingContext(): MaskingContext {
+  return {
+    mapping: {},
+    reverseMapping: {},
+    counters: {},
+  };
+}
+
+const PLACEHOLDER_FORMAT = "<{TYPE}_{N}>";
+
+/**
+ * Generates a placeholder for a PII entity type
+ */
+function generatePlaceholder(entityType: string, context: MaskingContext): string {
+  const count = (context.counters[entityType] || 0) + 1;
+  context.counters[entityType] = count;
+
+  return PLACEHOLDER_FORMAT.replace("{TYPE}", entityType).replace("{N}", String(count));
+}
+
+/**
+ * Masks PII entities in text, replacing them with placeholders
+ *
+ * First assigns placeholders in order of appearance (start position ascending),
+ * then replaces from end to start to maintain correct string positions
+ */
+export function mask(text: string, entities: PIIEntity[], context?: MaskingContext): MaskResult {
+  const ctx = context || createMaskingContext();
+
+  if (entities.length === 0) {
+    return { masked: text, context: ctx };
+  }
+
+  // First pass: sort by start position ascending to assign placeholders in order
+  const sortedByStart = [...entities].sort((a, b) => a.start - b.start);
+
+  // Assign placeholders in order of appearance
+  const entityPlaceholders = new Map<PIIEntity, string>();
+  for (const entity of sortedByStart) {
+    const originalValue = text.slice(entity.start, entity.end);
+
+    // Check if we already have a placeholder for this exact value
+    let placeholder = ctx.reverseMapping[originalValue];
+
+    if (!placeholder) {
+      placeholder = generatePlaceholder(entity.entity_type, ctx);
+      ctx.mapping[placeholder] = originalValue;
+      ctx.reverseMapping[originalValue] = placeholder;
+    }
+
+    entityPlaceholders.set(entity, placeholder);
+  }
+
+  // Second pass: sort by start position descending for replacement
+  // This ensures string indices remain valid as we replace
+  const sortedByEnd = [...entities].sort((a, b) => b.start - a.start);
+
+  let result = text;
+  for (const entity of sortedByEnd) {
+    const placeholder = entityPlaceholders.get(entity)!;
+    result = result.slice(0, entity.start) + placeholder + result.slice(entity.end);
+  }
+
+  return { masked: result, context: ctx };
+}
+
+/**
+ * Unmasks text by replacing placeholders with original values
+ *
+ * Optionally adds markers to indicate protected content
+ */
+export function unmask(text: string, context: MaskingContext, config: MaskingConfig): string {
+  let result = text;
+
+  // Sort placeholders by length descending to avoid partial replacements
+  const placeholders = Object.keys(context.mapping).sort((a, b) => b.length - a.length);
+
+  for (const placeholder of placeholders) {
+    const originalValue = context.mapping[placeholder];
+    const replacement = config.show_markers
+      ? `${config.marker_text}${originalValue}`
+      : originalValue;
+
+    // Replace all occurrences of the placeholder
+    result = result.split(placeholder).join(replacement);
+  }
+
+  return result;
+}
+
+/**
+ * Masks multiple messages (for chat completions)
+ */
+export function maskMessages(
+  messages: ChatMessage[],
+  entitiesByMessage: PIIEntity[][],
+): { masked: ChatMessage[]; context: MaskingContext } {
+  const context = createMaskingContext();
+
+  const masked = messages.map((msg, i) => {
+    const entities = entitiesByMessage[i] || [];
+    const { masked: maskedContent } = mask(msg.content, entities, context);
+    return { ...msg, content: maskedContent };
+  });
+
+  return { masked, context };
+}
+
+/**
+ * Streaming unmask helper - processes chunks and unmasks when complete placeholders are found
+ *
+ * Returns the unmasked portion and any remaining buffer that might contain partial placeholders
+ */
+export function unmaskStreamChunk(
+  buffer: string,
+  newChunk: string,
+  context: MaskingContext,
+  config: MaskingConfig,
+): { output: string; remainingBuffer: string } {
+  const combined = buffer + newChunk;
+
+  // Find the last safe position to unmask (before any potential partial placeholder)
+  // Look for the start of any potential placeholder pattern
+  const placeholderStart = combined.lastIndexOf("<");
+
+  if (placeholderStart === -1) {
+    // No potential placeholder, safe to unmask everything
+    return {
+      output: unmask(combined, context, config),
+      remainingBuffer: "",
+    };
+  }
+
+  // Check if there's a complete placeholder after the last <
+  const afterStart = combined.slice(placeholderStart);
+  const hasCompletePlaceholder = afterStart.includes(">");
+
+  if (hasCompletePlaceholder) {
+    // The placeholder is complete, safe to unmask everything
+    return {
+      output: unmask(combined, context, config),
+      remainingBuffer: "",
+    };
+  }
+
+  // Partial placeholder detected, buffer it
+  const safeToProcess = combined.slice(0, placeholderStart);
+  const toBuffer = combined.slice(placeholderStart);
+
+  return {
+    output: unmask(safeToProcess, context, config),
+    remainingBuffer: toBuffer,
+  };
+}
+
+/**
+ * Flushes remaining buffer at end of stream
+ */
+export function flushStreamBuffer(
+  buffer: string,
+  context: MaskingContext,
+  config: MaskingConfig,
+): string {
+  if (!buffer) return "";
+  return unmask(buffer, context, config);
+}
+
+/**
+ * Unmasks a chat completion response by replacing placeholders in all choices
+ */
+export function unmaskResponse(
+  response: ChatCompletionResponse,
+  context: MaskingContext,
+  config: MaskingConfig,
+): ChatCompletionResponse {
+  return {
+    ...response,
+    choices: response.choices.map((choice) => ({
+      ...choice,
+      message: {
+        ...choice.message,
+        content: unmask(choice.message.content, context, config),
+      },
+    })),
+  };
+}
diff --git a/src/services/pii-detector.ts b/src/services/pii-detector.ts
new file mode 100644 (file)
index 0000000..cff9772
--- /dev/null
@@ -0,0 +1,247 @@
+import { getConfig } from "../config";
+import {
+  getLanguageDetector,
+  type LanguageDetectionResult,
+  type SupportedLanguage,
+} from "./language-detector";
+
+export interface PIIEntity {
+  entity_type: string;
+  start: number;
+  end: number;
+  score: number;
+}
+
+interface AnalyzeRequest {
+  text: string;
+  language: string;
+  entities?: string[];
+  score_threshold?: number;
+}
+
+export interface PIIDetectionResult {
+  hasPII: boolean;
+  entitiesByMessage: PIIEntity[][];
+  newEntities: PIIEntity[];
+  scanTimeMs: number;
+  language: SupportedLanguage;
+  languageFallback: boolean;
+  detectedLanguage?: string;
+}
+
+export class PIIDetector {
+  private presidioUrl: string;
+  private scoreThreshold: number;
+  private entityTypes: string[];
+  private languageValidation?: { available: string[]; missing: string[] };
+
+  constructor() {
+    const config = getConfig();
+    this.presidioUrl = config.pii_detection.presidio_url;
+    this.scoreThreshold = config.pii_detection.score_threshold;
+    this.entityTypes = config.pii_detection.entities;
+  }
+
+  async detectPII(text: string, language: SupportedLanguage): Promise<PIIEntity[]> {
+    const analyzeEndpoint = `${this.presidioUrl}/analyze`;
+
+    const request: AnalyzeRequest = {
+      text,
+      language,
+      entities: this.entityTypes,
+      score_threshold: this.scoreThreshold,
+    };
+
+    try {
+      const response = await fetch(analyzeEndpoint, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify(request),
+        signal: AbortSignal.timeout(30_000),
+      });
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        throw new Error(
+          `Presidio API error: ${response.status} ${response.statusText} - ${errorText}`,
+        );
+      }
+
+      return (await response.json()) as PIIEntity[];
+    } catch (error) {
+      if (error instanceof Error) {
+        if (error.message.includes("fetch")) {
+          throw new Error(`Failed to connect to Presidio at ${this.presidioUrl}: ${error.message}`);
+        }
+        throw error;
+      }
+      throw new Error(`Unknown error during PII detection: ${error}`);
+    }
+  }
+
+  async analyzeMessages(
+    messages: Array<{ role: string; content: string }>,
+  ): Promise<PIIDetectionResult> {
+    const startTime = Date.now();
+
+    const lastUserIndex = messages.findLastIndex((m) => m.role === "user");
+
+    if (lastUserIndex === -1 || !messages[lastUserIndex].content) {
+      const config = getConfig();
+      return {
+        hasPII: false,
+        entitiesByMessage: messages.map(() => []),
+        newEntities: [],
+        scanTimeMs: Date.now() - startTime,
+        language: config.pii_detection.fallback_language,
+        languageFallback: false,
+      };
+    }
+
+    const text = messages[lastUserIndex].content;
+    const langResult = getLanguageDetector().detect(text);
+    const newEntities = await this.detectPII(text, langResult.language);
+
+    const entitiesByMessage = messages.map((_, i) => (i === lastUserIndex ? newEntities : []));
+
+    return {
+      hasPII: newEntities.length > 0,
+      entitiesByMessage,
+      newEntities,
+      scanTimeMs: Date.now() - startTime,
+      language: langResult.language,
+      languageFallback: langResult.usedFallback,
+      detectedLanguage: langResult.detectedLanguage,
+    };
+  }
+
+  async analyzeAllMessages(
+    messages: Array<{ role: string; content: string }>,
+    langResult: LanguageDetectionResult,
+  ): Promise<PIIDetectionResult> {
+    const startTime = Date.now();
+
+    const entitiesByMessage = await Promise.all(
+      messages.map((message) =>
+        message.content && (message.role === "user" || message.role === "assistant")
+          ? this.detectPII(message.content, langResult.language)
+          : Promise.resolve([]),
+      ),
+    );
+
+    return {
+      hasPII: entitiesByMessage.some((e) => e.length > 0),
+      entitiesByMessage,
+      newEntities: [],
+      scanTimeMs: Date.now() - startTime,
+      language: langResult.language,
+      languageFallback: langResult.usedFallback,
+      detectedLanguage: langResult.detectedLanguage,
+    };
+  }
+
+  async healthCheck(): Promise<boolean> {
+    try {
+      const response = await fetch(`${this.presidioUrl}/health`, {
+        method: "GET",
+        signal: AbortSignal.timeout(5000),
+      });
+      return response.ok;
+    } catch {
+      return false;
+    }
+  }
+
+  /**
+   * Wait for Presidio to be ready (for docker-compose startup order)
+   */
+  async waitForReady(maxRetries = 30, delayMs = 1000): Promise<boolean> {
+    for (let i = 1; i <= maxRetries; i++) {
+      if (await this.healthCheck()) {
+        return true;
+      }
+      if (i < maxRetries) {
+        // Show initial message, then every 5 attempts
+        if (i === 1) {
+          process.stdout.write("[STARTUP] Waiting for Presidio");
+        } else if (i % 5 === 0) {
+          process.stdout.write(".");
+        }
+        await new Promise((resolve) => setTimeout(resolve, delayMs));
+      }
+    }
+    process.stdout.write("\n");
+    return false;
+  }
+
+  /**
+   * Test if a language is supported by trying to analyze with it
+   */
+  async isLanguageSupported(language: string): Promise<boolean> {
+    try {
+      const response = await fetch(`${this.presidioUrl}/analyze`, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          text: "test",
+          language,
+          entities: ["PERSON"],
+        }),
+        signal: AbortSignal.timeout(5000),
+      });
+
+      // If we get a response (even empty array), the language is supported
+      // If we get an error like "No matching recognizers", it's not supported
+      if (response.ok) {
+        return true;
+      }
+
+      const errorText = await response.text();
+      return !errorText.includes("No matching recognizers");
+    } catch {
+      return false;
+    }
+  }
+
+  /**
+   * Validate multiple languages, return available/missing
+   */
+  async validateLanguages(languages: string[]): Promise<{
+    available: string[];
+    missing: string[];
+  }> {
+    const results = await Promise.all(
+      languages.map(async (lang) => ({
+        lang,
+        supported: await this.isLanguageSupported(lang),
+      })),
+    );
+
+    this.languageValidation = {
+      available: results.filter((r) => r.supported).map((r) => r.lang),
+      missing: results.filter((r) => !r.supported).map((r) => r.lang),
+    };
+
+    return this.languageValidation;
+  }
+
+  /**
+   * Get the cached language validation result
+   */
+  getLanguageValidation(): { available: string[]; missing: string[] } | undefined {
+    return this.languageValidation;
+  }
+}
+
+let detectorInstance: PIIDetector | null = null;
+
+export function getPIIDetector(): PIIDetector {
+  if (!detectorInstance) {
+    detectorInstance = new PIIDetector();
+  }
+  return detectorInstance;
+}
diff --git a/src/services/stream-transformer.test.ts b/src/services/stream-transformer.test.ts
new file mode 100644 (file)
index 0000000..0e43ef2
--- /dev/null
@@ -0,0 +1,153 @@
+import { describe, expect, test } from "bun:test";
+import type { MaskingConfig } from "../config";
+import { createMaskingContext } from "./masking";
+import { createUnmaskingStream } from "./stream-transformer";
+
+const defaultConfig: MaskingConfig = {
+  show_markers: false,
+  marker_text: "[protected]",
+};
+
+/**
+ * Helper to create a ReadableStream from SSE data
+ */
+function createSSEStream(chunks: string[]): ReadableStream<Uint8Array> {
+  const encoder = new TextEncoder();
+  let index = 0;
+
+  return new ReadableStream({
+    pull(controller) {
+      if (index < chunks.length) {
+        controller.enqueue(encoder.encode(chunks[index]));
+        index++;
+      } else {
+        controller.close();
+      }
+    },
+  });
+}
+
+/**
+ * Helper to consume a stream and return all chunks as string
+ */
+async function consumeStream(stream: ReadableStream<Uint8Array>): Promise<string> {
+  const reader = stream.getReader();
+  const decoder = new TextDecoder();
+  let result = "";
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    result += decoder.decode(value, { stream: true });
+  }
+
+  return result;
+}
+
+describe("createUnmaskingStream", () => {
+  test("unmasks complete placeholder in single chunk", async () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "test@test.com";
+
+    const sseData = `data: {"choices":[{"delta":{"content":"Hello <EMAIL_ADDRESS_1>!"}}]}\n\n`;
+    const source = createSSEStream([sseData]);
+
+    const unmaskedStream = createUnmaskingStream(source, context, defaultConfig);
+    const result = await consumeStream(unmaskedStream);
+
+    expect(result).toContain("Hello test@test.com!");
+  });
+
+  test("handles [DONE] message", async () => {
+    const context = createMaskingContext();
+
+    const chunks = [`data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n`, `data: [DONE]\n\n`];
+    const source = createSSEStream(chunks);
+
+    const unmaskedStream = createUnmaskingStream(source, context, defaultConfig);
+    const result = await consumeStream(unmaskedStream);
+
+    expect(result).toContain("data: [DONE]");
+  });
+
+  test("passes through non-content events", async () => {
+    const context = createMaskingContext();
+
+    const sseData = `data: {"choices":[{"delta":{}}]}\n\n`;
+    const source = createSSEStream([sseData]);
+
+    const unmaskedStream = createUnmaskingStream(source, context, defaultConfig);
+    const result = await consumeStream(unmaskedStream);
+
+    expect(result).toContain(`{"choices":[{"delta":{}}]}`);
+  });
+
+  test("buffers partial placeholder across chunks", async () => {
+    const context = createMaskingContext();
+    context.mapping["<EMAIL_ADDRESS_1>"] = "a@b.com";
+
+    // Split placeholder across chunks
+    const chunks = [
+      `data: {"choices":[{"delta":{"content":"Hello <EMAIL_"}}]}\n\n`,
+      `data: {"choices":[{"delta":{"content":"ADDRESS_1> world"}}]}\n\n`,
+    ];
+    const source = createSSEStream(chunks);
+
+    const unmaskedStream = createUnmaskingStream(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";
+
+    // Partial placeholder that completes only on flush
+    const chunks = [`data: {"choices":[{"delta":{"content":"Contact <EMAIL_ADDRESS_1>"}}]}\n\n`];
+    const source = createSSEStream(chunks);
+
+    const unmaskedStream = createUnmaskingStream(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 = `data: {"choices":[{"delta":{"content":"<PERSON_1>: <EMAIL_ADDRESS_1>"}}]}\n\n`;
+    const source = createSSEStream([sseData]);
+
+    const unmaskedStream = createUnmaskingStream(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 = createUnmaskingStream(source, context, defaultConfig);
+    const result = await consumeStream(unmaskedStream);
+
+    expect(result).toBe("");
+  });
+
+  test("passes through malformed data", async () => {
+    const context = createMaskingContext();
+
+    const chunks = [`data: not-json\n\n`];
+    const source = createSSEStream(chunks);
+
+    const unmaskedStream = createUnmaskingStream(source, context, defaultConfig);
+    const result = await consumeStream(unmaskedStream);
+
+    expect(result).toContain("not-json");
+  });
+});
diff --git a/src/services/stream-transformer.ts b/src/services/stream-transformer.ts
new file mode 100644 (file)
index 0000000..6405ee8
--- /dev/null
@@ -0,0 +1,102 @@
+import type { MaskingConfig } from "../config";
+import { flushStreamBuffer, type MaskingContext, unmaskStreamChunk } from "./masking";
+
+/**
+ * Creates a transform stream that unmasks SSE content
+ *
+ * Processes Server-Sent Events (SSE) chunks, buffering partial placeholders
+ * and unmasking complete ones before forwarding to the client.
+ */
+export function createUnmaskingStream(
+  source: ReadableStream<Uint8Array>,
+  context: MaskingContext,
+  config: MaskingConfig,
+): ReadableStream<Uint8Array> {
+  const decoder = new TextDecoder();
+  const encoder = new TextEncoder();
+  let contentBuffer = "";
+
+  return new ReadableStream({
+    async start(controller) {
+      const reader = source.getReader();
+
+      try {
+        while (true) {
+          const { done, value } = await reader.read();
+
+          if (done) {
+            // Flush remaining buffer content before closing
+            if (contentBuffer) {
+              const flushed = flushStreamBuffer(contentBuffer, context, config);
+              if (flushed) {
+                const finalEvent = {
+                  id: `flush-${Date.now()}`,
+                  object: "chat.completion.chunk",
+                  created: Math.floor(Date.now() / 1000),
+                  choices: [
+                    {
+                      index: 0,
+                      delta: { content: flushed },
+                      finish_reason: null,
+                    },
+                  ],
+                };
+                controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalEvent)}\n\n`));
+              }
+            }
+            controller.close();
+            break;
+          }
+
+          const chunk = decoder.decode(value, { stream: true });
+          const lines = chunk.split("\n");
+
+          for (const line of lines) {
+            if (line.startsWith("data: ")) {
+              const data = line.slice(6);
+
+              if (data === "[DONE]") {
+                controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+                continue;
+              }
+
+              try {
+                const parsed = JSON.parse(data);
+                const content = parsed.choices?.[0]?.delta?.content || "";
+
+                if (content) {
+                  // Use streaming unmask
+                  const { output, remainingBuffer } = unmaskStreamChunk(
+                    contentBuffer,
+                    content,
+                    context,
+                    config,
+                  );
+                  contentBuffer = remainingBuffer;
+
+                  if (output) {
+                    // Update the parsed object with unmasked content
+                    parsed.choices[0].delta.content = output;
+                    controller.enqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
+                  }
+                } else {
+                  // Pass through non-content events
+                  controller.enqueue(encoder.encode(`data: ${data}\n\n`));
+                }
+              } catch {
+                // Pass through unparseable data
+                controller.enqueue(encoder.encode(`${line}\n`));
+              }
+            } else if (line.trim()) {
+              controller.enqueue(encoder.encode(`${line}\n`));
+            }
+          }
+        }
+      } catch (error) {
+        controller.error(error);
+      } finally {
+        reader.releaseLock();
+      }
+    },
+  });
+}
diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx
new file mode 100644 (file)
index 0000000..1156628
--- /dev/null
@@ -0,0 +1,508 @@
+import type { FC } from "hono/jsx";
+
+const DashboardPage: FC = () => {
+       return (
+               <html lang="en">
+                       <head>
+                               <meta charset="UTF-8" />
+                               <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+                               <title>LLM-Shield Dashboard</title>
+                               <link rel="preconnect" href="https://fonts.googleapis.com" />
+                               <link
+                                       rel="preconnect"
+                                       href="https://fonts.gstatic.com"
+                                       crossOrigin="anonymous"
+                               />
+                               <link
+                                       href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600;700&display=swap"
+                                       rel="stylesheet"
+                               />
+                               <link rel="stylesheet" href="/dashboard/tailwind.css" />
+                               <style
+                                       // biome-ignore lint/security/noDangerouslySetInnerHtml: Custom CSS
+                                       dangerouslySetInnerHTML={{
+                                               __html: `
+                                                       :root {
+                                                               --page: #f8f7f4;
+                                                               --surface: #ffffff;
+                                                               --elevated: #fafaf9;
+                                                               --subtle: #f3f2ef;
+                                                               --detail: #fdfcfa;
+                                                               --border: #e5e3df;
+                                                               --border-subtle: #eeece8;
+                                                               --text-primary: #1a1917;
+                                                               --text-secondary: #5c5a56;
+                                                               --text-muted: #9c9a96;
+                                                               --amber: #d97706;
+                                                               --amber-light: #fef3c7;
+                                                               --blue: #2563eb;
+                                                               --blue-light: #dbeafe;
+                                                               --green: #059669;
+                                                               --green-light: #d1fae5;
+                                                               --teal: #0d9488;
+                                                               --teal-light: #ccfbf1;
+                                                       }
+                                                       body {
+                                                               font-family: 'Instrument Sans', -apple-system, BlinkMacSystemFont, sans-serif;
+                                                               background: var(--page);
+                                                               color: var(--text-primary);
+                                                       }
+                                                       .font-mono { font-family: 'DM Mono', 'SF Mono', monospace; }
+                                                       .bg-page { background: var(--page); }
+                                                       .bg-surface { background: var(--surface); }
+                                                       .bg-elevated { background: var(--elevated); }
+                                                       .bg-subtle { background: var(--subtle); }
+                                                       .bg-detail { background: var(--detail); }
+                                                       .bg-amber { background: var(--amber); }
+                                                       .bg-amber-light { background: var(--amber-light); }
+                                                       .bg-amber\\/10 { background: rgba(217, 119, 6, 0.1); }
+                                                       .bg-blue { background: var(--blue); }
+                                                       .bg-blue\\/10 { background: rgba(37, 99, 235, 0.1); }
+                                                       .bg-green { background: var(--green); }
+                                                       .bg-green\\/10 { background: rgba(5, 150, 105, 0.1); }
+                                                       .bg-teal { background: var(--teal); }
+                                                       .border-border { border-color: var(--border); }
+                                                       .border-border-subtle { border-color: var(--border-subtle); }
+                                                       .border-amber\\/20 { border-color: rgba(217, 119, 6, 0.2); }
+                                                       .border-green\\/20 { border-color: rgba(5, 150, 105, 0.2); }
+                                                       .text-text-primary { color: var(--text-primary); }
+                                                       .text-text-secondary { color: var(--text-secondary); }
+                                                       .text-text-muted { color: var(--text-muted); }
+                                                       .text-amber { color: var(--amber); }
+                                                       .text-blue { color: var(--blue); }
+                                                       .text-green { color: var(--green); }
+                                                       .text-teal { color: var(--teal); }
+                                                       @keyframes pulse {
+                                                               0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.3); }
+                                                               50% { opacity: 0.8; box-shadow: 0 0 0 4px rgba(5, 150, 105, 0); }
+                                                       }
+                                                       @keyframes spin {
+                                                               to { transform: rotate(360deg); }
+                                                       }
+                                                       @keyframes fadeIn {
+                                                               from { opacity: 0; transform: translateY(6px); }
+                                                               to { opacity: 1; transform: translateY(0); }
+                                                       }
+                                                       @keyframes slideDown {
+                                                               from { opacity: 0; transform: translateY(-8px); }
+                                                               to { opacity: 1; transform: translateY(0); }
+                                                       }
+                                                       .animate-pulse-dot { animation: pulse 2s ease-in-out infinite; }
+                                                       .animate-spin { animation: spin 0.8s linear infinite; }
+                                                       .animate-fade-in { animation: fadeIn 0.35s ease-out backwards; }
+                                                       .animate-slide-down { animation: slideDown 0.25s ease-out; }
+                                                       .route-only { display: none; }
+                                                       [data-mode="route"] .route-only { display: block; }
+                                                       [data-mode="route"] th.route-only,
+                                                       [data-mode="route"] td.route-only { display: table-cell; }
+                                               `,
+                                       }}
+                               />
+                       </head>
+                       <body class="bg-page text-text-primary min-h-screen font-sans antialiased leading-relaxed">
+                               <div class="max-w-[1320px] mx-auto p-8 px-6">
+                                       <Header />
+                                       <StatsGrid />
+                                       <Charts />
+                                       <LogsSection />
+                               </div>
+                               <ClientScript />
+                       </body>
+               </html>
+       );
+};
+
+const Header: FC = () => (
+       <header class="flex justify-between items-center mb-10">
+               <div class="flex items-center gap-2.5">
+                       <div class="w-9 h-9 bg-gradient-to-br from-slate-50 to-slate-200 border border-border rounded-lg flex items-center justify-center text-lg shadow-sm">
+                               🛡️
+                       </div>
+                       <div class="text-xl font-bold tracking-tight text-text-primary">
+                               LLM<span class="text-amber">Shield</span>
+                       </div>
+               </div>
+               <div class="flex items-center gap-4">
+                       <span
+                               id="mode-badge"
+                               class="inline-flex items-center px-3 py-1.5 rounded-lg font-mono text-[0.7rem] font-medium tracking-wide uppercase"
+                       >
+                               —
+                       </span>
+                       <div class="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border rounded-full text-xs text-text-secondary shadow-sm">
+                               <div class="w-[7px] h-[7px] bg-green rounded-full animate-pulse-dot" />
+                               <span>Live</span>
+                       </div>
+               </div>
+       </header>
+);
+
+const StatsGrid: FC = () => (
+       <div
+               id="stats-grid"
+               class="grid grid-cols-4 gap-4 mb-8 [&[data-mode='route']]:grid-cols-6"
+       >
+               <StatCard label="Total Requests" valueId="total-requests" />
+               <StatCard
+                       id="pii-card"
+                       label="Routed Local"
+                       labelId="pii-label"
+                       valueId="pii-requests"
+                       accent="amber"
+               />
+               <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"
+                       accent="blue"
+                       routeOnly
+               />
+               <StatCard
+                       id="local-card"
+                       label="Local"
+                       valueId="local-requests"
+                       accent="green"
+                       routeOnly
+               />
+       </div>
+);
+
+const StatCard: FC<{
+       id?: string;
+       label: string;
+       labelId?: string;
+       valueId: string;
+       accent?: "amber" | "blue" | "green" | "teal";
+       routeOnly?: boolean;
+}> = ({ id, label, labelId, valueId, accent, routeOnly }) => {
+       const accentClass = accent
+               ? {
+                               amber: "text-amber",
+                               blue: "text-blue",
+                               green: "text-green",
+                               teal: "text-teal",
+                       }[accent]
+               : "";
+
+       return (
+               <div
+                       id={id}
+                       class={`bg-surface border border-border-subtle rounded-xl p-5 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5 animate-fade-in ${routeOnly ? "route-only" : ""}`}
+               >
+                       <div
+                               id={labelId}
+                               class="text-[0.7rem] font-medium uppercase tracking-widest text-text-muted mb-2"
+                       >
+                               {label}
+                       </div>
+                       <div
+                               id={valueId}
+                               class={`text-3xl font-bold tabular-nums tracking-tight ${accentClass}`}
+                       >
+                               —
+                       </div>
+               </div>
+       );
+};
+
+const Charts: FC = () => (
+       <div class="grid grid-cols-1 gap-4 mb-8 [&[data-mode='route']]:grid-cols-2">
+               <div
+                       id="provider-chart"
+                       class="route-only bg-surface border border-border-subtle rounded-xl p-6 shadow-sm animate-fade-in"
+               >
+                       <div class="text-[0.8rem] font-semibold text-text-secondary mb-5 uppercase tracking-wide">
+                               Provider Distribution
+                       </div>
+                       <div
+                               id="provider-split"
+                               class="flex h-10 rounded-lg overflow-hidden bg-subtle"
+                       >
+                               <div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-blue min-w-[48px] transition-all duration-400 w-1/2">
+                                       50%
+                               </div>
+                               <div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-green min-w-[48px] transition-all duration-400 w-1/2">
+                                       50%
+                               </div>
+                       </div>
+                       <div class="flex gap-6 mt-4">
+                               <div class="flex items-center gap-2 text-xs text-text-secondary">
+                                       <div class="w-2.5 h-2.5 rounded bg-blue" />
+                                       <span>Upstream</span>
+                               </div>
+                               <div class="flex items-center gap-2 text-xs text-text-secondary">
+                                       <div class="w-2.5 h-2.5 rounded bg-green" />
+                                       <span>Local</span>
+                               </div>
+                       </div>
+               </div>
+               <div
+                       id="entity-chart-card"
+                       class="bg-surface border border-border-subtle rounded-xl p-6 shadow-sm animate-fade-in"
+               >
+                       <div class="text-[0.8rem] font-semibold text-text-secondary mb-5 uppercase tracking-wide">
+                               Entity Types Detected
+                       </div>
+                       <div id="entity-chart" class="flex flex-col gap-2.5">
+                               <div class="text-center py-10 text-text-muted">
+                                       <div class="text-2xl mb-3 opacity-40">📊</div>
+                                       <div class="text-sm">No PII detected yet</div>
+                               </div>
+                       </div>
+               </div>
+       </div>
+);
+
+const LogsSection: FC = () => (
+       <>
+               <div class="text-[0.8rem] font-semibold text-text-secondary mb-4 uppercase tracking-wide">
+                       Recent Requests
+               </div>
+               <div class="bg-surface border border-border-subtle rounded-xl shadow-sm overflow-hidden animate-fade-in">
+                       <div class="overflow-x-auto">
+                               <table class="w-full min-w-[700px] border-collapse">
+                                       <thead>
+                                               <tr>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Time
+                                                       </th>
+                                                       <th class="route-only bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Provider
+                                                       </th>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Model
+                                                       </th>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Language
+                                                       </th>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               PII Entities
+                                                       </th>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Scan Time
+                                                       </th>
+                                               </tr>
+                                       </thead>
+                                       <tbody id="logs-body">
+                                               <tr>
+                                                       <td colSpan={6}>
+                                                               <div class="flex justify-center items-center p-10 text-text-muted text-sm">
+                                                                       <div class="w-[18px] h-[18px] border-2 border-border border-t-amber rounded-full animate-spin mr-2.5" />
+                                                                       Loading...
+                                                               </div>
+                                                       </td>
+                                               </tr>
+                                       </tbody>
+                               </table>
+                       </div>
+               </div>
+       </>
+);
+
+const ClientScript: FC = () => (
+       <script
+               // biome-ignore lint/security/noDangerouslySetInnerHtml: Client-side JS
+               dangerouslySetInnerHTML={{
+                       __html: `
+let currentMode = null;
+let expandedRowId = null;
+
+async function fetchStats() {
+  try {
+    const res = await fetch('/dashboard/api/stats');
+    const data = await res.json();
+
+    if (currentMode !== data.mode) {
+      currentMode = data.mode;
+      document.body.dataset.mode = data.mode;
+    }
+
+    document.getElementById('total-requests').textContent = data.total_requests.toLocaleString();
+    document.getElementById('avg-scan').textContent = data.avg_scan_time_ms + 'ms';
+    document.getElementById('requests-hour').textContent = data.requests_last_hour.toLocaleString();
+
+    const modeBadge = document.getElementById('mode-badge');
+    modeBadge.textContent = data.mode.toUpperCase();
+    modeBadge.className = data.mode === 'route'
+      ? 'inline-flex items-center px-3 py-1.5 rounded-lg font-mono text-[0.7rem] font-medium tracking-wide uppercase bg-green/10 text-green border border-green/20'
+      : 'inline-flex items-center px-3 py-1.5 rounded-lg font-mono text-[0.7rem] font-medium tracking-wide uppercase bg-amber/10 text-amber border border-amber/20';
+
+    const piiLabel = document.getElementById('pii-label');
+    if (data.mode === 'mask') {
+      piiLabel.textContent = 'Masked';
+      document.getElementById('pii-requests').textContent = data.pii_requests.toLocaleString() + ' (' + data.pii_percentage + '%)';
+    } else {
+      piiLabel.textContent = 'Routed Local';
+      document.getElementById('pii-requests').textContent = data.local_requests.toLocaleString();
+    }
+
+    if (data.mode === 'route') {
+      document.getElementById('upstream-requests').textContent = data.upstream_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;
+
+      document.getElementById('provider-split').innerHTML =
+        '<div class="flex items-center justify-center font-mono text-[0.7rem] font-medium text-white bg-blue min-w-[48px] transition-all duration-400" 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-green min-w-[48px] transition-all duration-400" style="width:' + Math.max(localPct, 10) + '%">' + localPct + '%</div>';
+    }
+
+    const chartEl = document.getElementById('entity-chart');
+    if (data.entity_breakdown && data.entity_breakdown.length > 0) {
+      const maxCount = Math.max(...data.entity_breakdown.map(e => e.count));
+      chartEl.innerHTML = data.entity_breakdown.slice(0, 6).map(e =>
+        '<div class="grid grid-cols-[100px_1fr_40px] items-center gap-3">' +
+          '<div class="font-mono text-[0.65rem] text-text-secondary truncate">' + e.entity + '</div>' +
+          '<div class="h-1.5 bg-subtle rounded overflow-hidden">' +
+            '<div class="h-full bg-gradient-to-r from-amber to-amber-700 rounded transition-all duration-400" style="width:' + ((e.count / maxCount) * 100) + '%"></div>' +
+          '</div>' +
+          '<div class="font-mono text-[0.7rem] font-medium text-right text-text-primary">' + e.count + '</div>' +
+        '</div>'
+      ).join('');
+    } else {
+      chartEl.innerHTML = '<div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📊</div><div class="text-sm">No PII detected yet</div></div>';
+    }
+  } catch (err) {
+    console.error('Failed to fetch stats:', err);
+  }
+}
+
+function toggleRow(logId) {
+  const wasExpanded = expandedRowId === logId;
+
+  // Hide all detail rows and reset all arrows
+  document.querySelectorAll('.detail-row-visible').forEach(el => {
+    el.classList.remove('detail-row-visible');
+    el.classList.add('hidden');
+  });
+  document.querySelectorAll('.log-row-expanded').forEach(el => el.classList.remove('log-row-expanded'));
+  document.querySelectorAll('.arrow-icon').forEach(el => {
+    el.classList.remove('rotate-90', 'bg-amber/10', 'text-amber');
+    el.classList.add('bg-subtle', 'text-text-muted');
+  });
+
+  if (!wasExpanded) {
+    const logRow = document.getElementById('log-' + logId);
+    const detailRow = document.getElementById('detail-' + logId);
+    const arrow = document.getElementById('arrow-' + logId);
+
+    if (logRow && detailRow) {
+      logRow.classList.add('log-row-expanded');
+      detailRow.classList.remove('hidden');
+      detailRow.classList.add('detail-row-visible');
+
+      if (arrow) {
+        arrow.classList.remove('bg-subtle', 'text-text-muted');
+        arrow.classList.add('rotate-90', 'bg-amber/10', 'text-amber');
+      }
+
+      expandedRowId = logId;
+    }
+  } else {
+    expandedRowId = null;
+  }
+}
+
+function formatMaskedPreview(maskedContent, entities) {
+  if (maskedContent) {
+    return maskedContent
+      .replace(/&/g, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/&lt;([A-Z_]+_\\d+)&gt;/g, '<span class="bg-amber-light text-amber px-1 py-0.5 rounded font-medium">&lt;$1&gt;</span>');
+  }
+  if (!entities || entities.length === 0) {
+    return '<span class="text-text-muted">No PII detected in this request</span>';
+  }
+  return '<span class="text-text-muted">Masked content not logged (log_masked_content: false)</span>';
+}
+
+function renderEntityList(entities) {
+  if (!entities || entities.length === 0) {
+    return '<div class="text-sm text-text-muted p-3 bg-surface border border-dashed border-border rounded-lg text-center">No entities detected</div>';
+  }
+  const counts = {};
+  for (const e of entities) counts[e] = (counts[e] || 0) + 1;
+  return '<div class="flex flex-col gap-1.5">' + Object.entries(counts).map(([type, count]) =>
+    '<div class="flex items-center gap-2.5 text-xs p-2 px-3 bg-surface border border-border-subtle rounded-lg">' +
+      '<span class="font-mono text-[0.65rem] font-medium px-1.5 py-0.5 bg-amber/10 text-amber rounded">' + type + '</span>' +
+      '<span class="font-mono text-[0.7rem] text-text-primary flex-1">' + count + ' ' + (count === 1 ? 'instance' : 'instances') + '</span>' +
+    '</div>'
+  ).join('') + '</div>';
+}
+
+async function fetchLogs() {
+  try {
+    const res = await fetch('/dashboard/api/logs?limit=50');
+    const data = await res.json();
+    const tbody = document.getElementById('logs-body');
+
+    if (data.logs.length === 0) {
+      tbody.innerHTML = '<tr><td colspan="6"><div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📋</div><div class="text-sm">No requests yet</div></div></td></tr>';
+      return;
+    }
+
+    tbody.innerHTML = data.logs.map((log, index) => {
+      const time = new Date(log.timestamp).toLocaleTimeString();
+      const entities = log.entities ? log.entities.split(',').filter(e => e.trim()) : [];
+      const lang = log.language || 'en';
+      const detectedLang = log.detected_language;
+
+      const formatLang = (code) => code ? code.toUpperCase() : lang.toUpperCase();
+
+      // Show original→fallback when fallback was used (e.g. FR→EN)
+      const langDisplay = log.language_fallback && detectedLang
+        ? '<span class="text-amber" title="Language not supported, fallback used">' + formatLang(detectedLang) + '</span><span class="text-text-muted text-[0.5rem] mx-0.5">→</span><span>' + lang.toUpperCase() + '</span>'
+        : lang.toUpperCase();
+      const logId = log.id || index;
+      const isExpanded = expandedRowId === logId;
+
+      const mainRow =
+        '<tr id="log-' + logId + '" class="cursor-pointer transition-colors hover:bg-elevated ' + (isExpanded ? 'log-row-expanded bg-elevated' : '') + '" onclick="toggleRow(' + logId + ')">' +
+          '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
+            '<span id="arrow-' + logId + '" class="arrow-icon inline-flex items-center justify-center w-[18px] h-[18px] mr-2 rounded bg-subtle text-text-muted text-[0.65rem] transition-transform ' + (isExpanded ? 'rotate-90 bg-amber/10 text-amber' : '') + '">▶</span>' +
+            '<span class="font-mono text-[0.7rem] text-text-secondary">' + time + '</span>' +
+          '</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 font-mono text-[0.6rem] font-medium uppercase tracking-wide ' +
+              (log.provider === 'upstream' ? 'bg-blue/10 text-blue' : 'bg-green/10 text-green') + '">' + 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>' +
+          '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
+            (entities.length > 0
+              ? '<div class="flex flex-wrap gap-1">' + entities.map(e => '<span class="font-mono text-[0.55rem] px-1.5 py-0.5 bg-subtle border border-border rounded text-text-secondary">' + e.trim() + '</span>').join('') + '</div>'
+              : '<span class="text-text-muted">—</span>') +
+          '</td>' +
+          '<td class="font-mono text-[0.7rem] text-teal px-4 py-3 border-b border-border-subtle align-middle">' + log.scan_time_ms + 'ms</td>' +
+        '</tr>';
+
+      const detailRow =
+        '<tr id="detail-' + logId + '" class="' + (isExpanded ? 'detail-row-visible' : 'hidden') + '">' +
+          '<td colspan="6" class="p-0 bg-detail border-b border-border-subtle">' +
+            '<div class="p-4 px-5 animate-slide-down">' +
+              '<div class="font-mono text-xs leading-relaxed text-text-secondary bg-surface border border-border-subtle rounded-lg p-3 whitespace-pre-wrap break-words">' + formatMaskedPreview(log.masked_content, entities) + '</div>' +
+            '</div>' +
+          '</td>' +
+        '</tr>';
+
+      return mainRow + detailRow;
+    }).join('');
+  } catch (err) {
+    console.error('Failed to fetch logs:', err);
+  }
+}
+
+fetchStats();
+fetchLogs();
+setInterval(() => { fetchStats(); fetchLogs(); }, 5000);
+                       `,
+               }}
+       />
+);
+
+export default DashboardPage;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644 (file)
index 0000000..e9dfe07
--- /dev/null
@@ -0,0 +1,22 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "strict": true,
+    "skipLibCheck": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "types": ["bun"],
+    "lib": ["ESNext", "DOM"],
+    "outDir": "dist",
+    "rootDir": "src",
+    "jsx": "react-jsx",
+    "jsxImportSource": "hono/jsx"
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx"],
+  "exclude": ["node_modules", "dist"]
+}
git clone https://git.99rst.org/PROJECT