--- /dev/null
+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
--- /dev/null
+# 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
--- /dev/null
+# 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.
--- /dev/null
+# 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)
--- /dev/null
+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"]
--- /dev/null
+ 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.
--- /dev/null
+# 🛡️ LLM-Shield
+
+[](https://github.com/sgasser/llm-shield/actions/workflows/ci.yml)
+[](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)
--- /dev/null
+{
+ "$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"
+ }
+ }
+}
--- /dev/null
+{
+ "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=="],
+ }
+}
--- /dev/null
+# 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
--- /dev/null
+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
--- /dev/null
+{
+ "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"
+ }
+}
--- /dev/null
+# 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()'"]
--- /dev/null
+# 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:
+ - телефон
+ - мобільний
+ - дзвонити
+ - факс
+
--- /dev/null
+#!/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()
--- /dev/null
+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;
+}
--- /dev/null
+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);
+}
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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");
+}
--- /dev/null
+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 />);
+});
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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,
+ );
+});
--- /dev/null
+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");
+ });
+});
--- /dev/null
+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);
+});
--- /dev/null
+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");
+ });
+ });
+});
--- /dev/null
+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;
+}
--- /dev/null
+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;
+}
--- /dev/null
+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,
+ };
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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");
+ });
+});
--- /dev/null
+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),
+ },
+ })),
+ };
+}
--- /dev/null
+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;
+}
--- /dev/null
+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");
+ });
+});
--- /dev/null
+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();
+ }
+ },
+ });
+}
--- /dev/null
+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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/<([A-Z_]+_\\d+)>/g, '<span class="bg-amber-light text-amber px-1 py-0.5 rounded font-medium"><$1></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;
--- /dev/null
+{
+ "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"]
+}