mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Merge pull request #79 from diegosouzapw/features-improvements
v1.0.2 — Security Hardening, Architecture & UX Polish
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -36,7 +36,8 @@ jobs:
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx is-my-node-vulnerable || true
|
||||
run: npx is-my-node-vulnerable
|
||||
continue-on-error: true
|
||||
|
||||
build:
|
||||
name: Build
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.0.2] — 2026-02-18
|
||||
|
||||
> ### 🔒 Security Hardening, Architecture Improvements & UX Polish
|
||||
>
|
||||
> Comprehensive audit-driven improvements across security, architecture, testing, and user experience.
|
||||
|
||||
### 🛡️ Security (Phase 0)
|
||||
|
||||
- **Auth guard** — API route protection via `withAuth` middleware for all dashboard routes
|
||||
- **CSRF protection** — Token-based CSRF guard for all state-changing API routes
|
||||
- **Request payload validation** — Zod schemas for provider, combo, key, and settings endpoints
|
||||
- **Prompt injection guard** — Input sanitization against malicious prompt patterns
|
||||
- **Body size guard** — Route-specific body size limits with dedicated audio upload threshold
|
||||
- **Rate limiter** — Per-IP rate limiting with configurable windows and thresholds
|
||||
|
||||
### 🏗️ Architecture (Phase 1–2)
|
||||
|
||||
- **DI container** — Simple dependency injection container for service registration
|
||||
- **Policy engine** — Consolidated `PolicyEngine` for routing, security, and rate limiting
|
||||
- **SQLite migration** — Database migration system with versioned migration runner
|
||||
- **Graceful shutdown** — Clean server shutdown with connection draining
|
||||
- **TypeScript fixes** — Resolved all `tsc` errors; removed redundant `@ts-check` directives
|
||||
- **Pipeline decomposition** — `handleSingleModelChat` decomposed into composable pipeline stages
|
||||
- **Prompt template versioning** — Version-tracked prompt templates with rollback support
|
||||
- **Eval scheduling** — Automated evaluation suite scheduling with cron-based runner
|
||||
- **Plugin architecture** — Extensible plugin system for custom middleware and handlers
|
||||
|
||||
### 🧪 Testing & CI (Phase 2)
|
||||
|
||||
- **Coverage thresholds** — Jest coverage thresholds enforced in CI (368 tests passing)
|
||||
- **Proxy pipeline integration tests** — End-to-end tests for the proxy request pipeline
|
||||
- **CI audit workflow** — npm audit and security scanning in GitHub Actions
|
||||
- **k6 load tests** — Performance testing with ramping VUs and custom metrics
|
||||
|
||||
### ✨ UX & Polish (Phase 3–4)
|
||||
|
||||
- **Session management** — Session info card with login time, age, user agent, and logout
|
||||
- **Focus indicators** — Global `:focus-visible` styles and `--focus-ring` CSS utility
|
||||
- **Audit log viewer** — Security event audit log with structured data display
|
||||
- **Dashboard cleanup** — Removed unused files, fixed Quick Start links to Endpoint page
|
||||
- **Documentation** — Troubleshooting guide, deployment improvements
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] — 2026-02-18
|
||||
|
||||
> ### 🔧 API Compatibility & SDK Hardening
|
||||
@@ -188,4 +232,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
---
|
||||
|
||||
[1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0
|
||||
[1.0.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.2
|
||||
[1.0.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.0
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Image | Tag | Größe | Beschreibung |
|
||||
| ------------------------ | -------- | ------ | ------------------------ |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Letztes stabiles Release |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Aktuelle Version |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Aktuelle Version |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
|
||||
**Verbindungstest zeigt „Invalid" für OpenAI-kompatible Anbieter**
|
||||
|
||||
- Viele Anbieter stellen den `/models` Endpoint nicht bereit
|
||||
- OmniRoute v1.0.0+ enthält Fallback-Validierung via Chat Completions
|
||||
- OmniRoute v1.0.2+ enthält Fallback-Validierung via Chat Completions
|
||||
- Stelle sicher, dass die Base URL den `/v1` Suffix enthält
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle:
|
||||
## 🛠️ Technologie-Stack
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.0)
|
||||
- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Datenbank**: LowDB (JSON) + SQLite (Domain-Status + Proxy-Logs)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für detaillierte Richtlinien.
|
||||
|
||||
```bash
|
||||
# Release erstellen — npm-Veröffentlichung erfolgt automatisch
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Imagen | Tag | Tamaño | Descripción |
|
||||
| ------------------------ | -------- | ------ | ---------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versión estable |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versión actual |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versión actual |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
|
||||
**Prueba de conexión muestra "Invalid" para proveedores compatibles con OpenAI**
|
||||
|
||||
- Muchos proveedores no exponen el endpoint `/models`
|
||||
- OmniRoute v1.0.0+ incluye validación vía chat completions como fallback
|
||||
- OmniRoute v1.0.2+ incluye validación vía chat completions como fallback
|
||||
- Asegúrate de que la URL base incluya el sufijo `/v1`
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren:
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.0)
|
||||
- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Base de Datos**: LowDB (JSON) + SQLite (estado del dominio + logs de proxy)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para directrices detalladas.
|
||||
|
||||
```bash
|
||||
# Crea un release — la publicación en npm ocurre automáticamente
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Image | Tag | Taille | Description |
|
||||
| ------------------------ | -------- | ------ | ----------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Dernière version stable |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Version actuelle |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Version actuelle |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
|
||||
**Le test de connexion affiche « Invalid » pour les fournisseurs compatibles OpenAI**
|
||||
|
||||
- Beaucoup de fournisseurs n'exposent pas le point de terminaison `/models`
|
||||
- OmniRoute v1.0.0+ inclut une validation de secours via chat completions
|
||||
- OmniRoute v1.0.2+ inclut une validation de secours via chat completions
|
||||
- Assurez-vous que l'URL de base inclut le suffixe `/v1`
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test :
|
||||
## 🛠️ Stack technologique
|
||||
|
||||
- **Runtime** : Node.js 20+
|
||||
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.0)
|
||||
- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.2)
|
||||
- **Framework** : Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Base de données** : LowDB (JSON) + SQLite (état du domaine + logs proxy)
|
||||
- **Streaming** : Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives détaillées.
|
||||
|
||||
```bash
|
||||
# Créer un release — la publication npm est automatique
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Immagine | Tag | Dimensione | Descrizione |
|
||||
| ------------------------ | -------- | ---------- | ----------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Ultima versione stabile |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versione attuale |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versione attuale |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
|
||||
**Il test di connessione mostra "Invalid" per provider compatibili OpenAI**
|
||||
|
||||
- Molti provider non espongono l'endpoint `/models`
|
||||
- OmniRoute v1.0.0+ include validazione fallback tramite chat completions
|
||||
- OmniRoute v1.0.2+ include validazione fallback tramite chat completions
|
||||
- Assicurati che la URL base includa il suffisso `/v1`
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test:
|
||||
## 🛠️ Stack Tecnologico
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.0)
|
||||
- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Database**: LowDB (JSON) + SQLite (stato dominio + log proxy)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida dettagliate.
|
||||
|
||||
```bash
|
||||
# Crea un rilascio — la pubblicazione npm avviene automaticamente
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Image | Tag | Size | Description |
|
||||
| ------------------------ | -------- | ------ | --------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Current version |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Current version |
|
||||
|
||||
---
|
||||
|
||||
@@ -903,7 +903,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
|
||||
**Connection test shows "Invalid" for OpenAI-compatible providers**
|
||||
|
||||
- Many providers don't expose a `/models` endpoint
|
||||
- OmniRoute v1.0.0+ includes fallback validation via chat completions
|
||||
- OmniRoute v1.0.2+ includes fallback validation via chat completions
|
||||
- Ensure base URL includes `/v1` suffix
|
||||
|
||||
</details>
|
||||
@@ -913,7 +913,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering:
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.0)
|
||||
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
@@ -1014,7 +1014,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
```bash
|
||||
# Create a release — npm publish happens automatically
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Imagem | Tag | Tamanho | Descrição |
|
||||
| ------------------------ | -------- | ------- | --------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Última versão estável |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Versão atual |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versão atual |
|
||||
|
||||
---
|
||||
|
||||
@@ -899,7 +899,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
|
||||
**Teste de conexão mostra "Invalid" para provedores compatíveis com OpenAI**
|
||||
|
||||
- Muitos provedores não expõem endpoint `/models`
|
||||
- OmniRoute v1.0.0+ inclui validação via chat completions como fallback
|
||||
- OmniRoute v1.0.2+ inclui validação via chat completions como fallback
|
||||
- Certifique-se de que a base URL inclui sufixo `/v1`
|
||||
|
||||
</details>
|
||||
@@ -909,7 +909,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo:
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.0)
|
||||
- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Banco de Dados**: LowDB (JSON) + SQLite (estado do domínio + logs de proxy)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
@@ -1010,7 +1010,7 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes detalhadas.
|
||||
|
||||
```bash
|
||||
# Crie um release — publicação no npm acontece automaticamente
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| Образ | Тег | Размер | Описание |
|
||||
| ------------------------ | -------- | ------ | -------------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Последний стабильный релиз |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | Текущая версия |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Текущая версия |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ OmniRoute включает встроенный фреймворк оценки
|
||||
**Тест подключения показывает «Invalid» для OpenAI-совместимых провайдеров**
|
||||
|
||||
- Многие провайдеры не предоставляют endpoint `/models`
|
||||
- OmniRoute v1.0.0+ включает fallback-валидацию через chat completions
|
||||
- OmniRoute v1.0.2+ включает fallback-валидацию через chat completions
|
||||
- Убедитесь что base URL содержит суффикс `/v1`
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ OmniRoute включает встроенный фреймворк оценки
|
||||
## 🛠️ Технологический стек
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.0)
|
||||
- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.2)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **База данных**: LowDB (JSON) + SQLite (состояние домена + proxy-логи)
|
||||
- **Стриминг**: Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ OmniRoute включает встроенный фреймворк оценки
|
||||
|
||||
```bash
|
||||
# Создайте релиз — публикация в npm происходит автоматически
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ docker compose --profile cli up -d
|
||||
| 镜像 | 标签 | 大小 | 描述 |
|
||||
| ------------------------ | -------- | ------ | ---------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | 最新稳定版 |
|
||||
| `diegosouzapw/omniroute` | `1.0.0` | ~250MB | 当前版本 |
|
||||
| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | 当前版本 |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +892,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
|
||||
**兼容 OpenAI 的提供商连接测试显示 "Invalid"**
|
||||
|
||||
- 许多提供商不暴露 `/models` 端点
|
||||
- OmniRoute v1.0.0+ 包含通过 chat completions 的回退验证
|
||||
- OmniRoute v1.0.2+ 包含通过 chat completions 的回退验证
|
||||
- 确保 base URL 包含 `/v1` 后缀
|
||||
|
||||
</details>
|
||||
@@ -902,7 +902,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **运行时**: Node.js 20+
|
||||
- **语言**: TypeScript 5.9 — `src/` 和 `open-sse/` 中 **100% TypeScript**(v1.0.0)
|
||||
- **语言**: TypeScript 5.9 — `src/` 和 `open-sse/` 中 **100% TypeScript**(v1.0.2)
|
||||
- **框架**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **数据库**: LowDB (JSON) + SQLite(领域状态 + 代理日志)
|
||||
- **流式传输**: Server-Sent Events (SSE)
|
||||
@@ -957,7 +957,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质
|
||||
|
||||
```bash
|
||||
# 创建发布 — npm 发布自动完成
|
||||
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||
gh release create v1.0.2 --title "v1.0.2" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
|
||||
|
||||
| Version | Support Status |
|
||||
| ------- | -------------- |
|
||||
| 0.8.x | ✅ Active |
|
||||
| 0.7.x | ✅ Security |
|
||||
| < 0.7.0 | ❌ Unsupported |
|
||||
| 1.0.x | ✅ Active |
|
||||
| 0.8.x | ✅ Security |
|
||||
| < 0.8.0 | ❌ Unsupported |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
@@ -14,6 +15,9 @@ const eslintConfig = [
|
||||
// Relaxed rules for open-sse and tests (incremental adoption)
|
||||
{
|
||||
files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@next/next/no-assign-module-variable": "off",
|
||||
|
||||
@@ -6,9 +6,8 @@ const nextConfig = {
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
typescript: {
|
||||
// Migration Phase: ignore TS errors during build.
|
||||
// Remove after all 984 type errors are resolved.
|
||||
ignoreBuildErrors: true,
|
||||
// All TS errors resolved — strict checking enforced
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Audio Speech Handler (TTS)
|
||||
*
|
||||
@@ -40,7 +41,10 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "audio/mpeg",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -77,7 +81,10 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,7 +93,7 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
});
|
||||
@@ -154,7 +161,10 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +174,7 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Audio Transcription Handler
|
||||
*
|
||||
@@ -46,7 +47,10 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +58,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
|
||||
// Transform Deepgram response to OpenAI Whisper format
|
||||
const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
|
||||
|
||||
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +82,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
|
||||
const errText = await uploadRes.text();
|
||||
return new Response(errText, {
|
||||
status: uploadRes.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +109,10 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
|
||||
const errText = await submitRes.text();
|
||||
return new Response(errText, {
|
||||
status: submitRes.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +134,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
|
||||
if (result.status === "completed") {
|
||||
return Response.json(
|
||||
{ text: result.text || "" },
|
||||
{ headers: { "Access-Control-Allow-Origin": "*" } }
|
||||
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,7 +192,11 @@ export async function handleAudioTranscription({ formData, credentials }) {
|
||||
|
||||
// Default: OpenAI/Groq-compatible multipart proxy
|
||||
const upstreamForm = new FormData();
|
||||
upstreamForm.append("file", /** @type {Blob} */ (file), /** @type {any} */ (file).name || "audio.wav");
|
||||
upstreamForm.append(
|
||||
"file",
|
||||
/** @type {Blob} */ file,
|
||||
/** @type {any} */ file.name || "audio.wav"
|
||||
);
|
||||
upstreamForm.append("model", modelId);
|
||||
|
||||
// Forward optional parameters
|
||||
@@ -195,7 +209,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
|
||||
]) {
|
||||
const val = formData.get(key);
|
||||
if (val !== null && val !== undefined) {
|
||||
upstreamForm.append(key, /** @type {string} */ (val));
|
||||
upstreamForm.append(key, /** @type {string} */ val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +224,10 @@ export async function handleAudioTranscription({ formData, credentials }) {
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,7 +236,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
|
||||
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": "*" },
|
||||
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(500, `Transcription request failed: ${err.message}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
import { detectFormat, getTargetFormat } from "../services/provider.ts";
|
||||
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
@@ -82,7 +83,7 @@ export async function handleChatCore({
|
||||
status: cachedIdemp.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
"X-OmniRoute-Idempotent": "true",
|
||||
},
|
||||
}),
|
||||
@@ -123,7 +124,7 @@ export async function handleChatCore({
|
||||
response: new Response(JSON.stringify(cached), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
"X-OmniRoute-Cache": "HIT",
|
||||
},
|
||||
}),
|
||||
@@ -189,7 +190,7 @@ export async function handleChatCore({
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -514,7 +515,7 @@ export async function handleChatCore({
|
||||
response: new Response(JSON.stringify(translatedResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
"X-OmniRoute-Cache": "MISS",
|
||||
},
|
||||
}),
|
||||
@@ -532,7 +533,7 @@ export async function handleChatCore({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
};
|
||||
|
||||
// Create transform stream with logger for streaming response
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Moderation Handler
|
||||
*
|
||||
@@ -55,13 +56,16 @@ export async function handleModeration({ body, credentials }) {
|
||||
const errText = await res.text();
|
||||
return new Response(errText, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return Response.json(data, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(500, `Moderation request failed: ${err.message}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Rerank Handler
|
||||
*
|
||||
@@ -129,7 +130,7 @@ export async function handleRerank({
|
||||
const result = transformResponseFromProvider(providerConfig, data);
|
||||
|
||||
return Response.json(result, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(500, `Rerank request failed: ${err.message}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Responses API Handler for Workers
|
||||
* Converts Chat Completions to Codex Responses API format
|
||||
@@ -75,7 +76,7 @@ export async function handleResponsesCore({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "./cors.ts";
|
||||
import { detectFormat } from "../services/provider.ts";
|
||||
import { translateResponse, initState } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
@@ -122,7 +123,7 @@ function createNonStreamingResponse(sourceFormat, model) {
|
||||
response: new Response(JSON.stringify(openaiResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -156,7 +157,7 @@ function createNonStreamingResponse(sourceFormat, model) {
|
||||
response: new Response(JSON.stringify(finalResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -204,7 +205,7 @@ function createStreamingResponse(sourceFormat, model) {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
22
open-sse/utils/cors.ts
Normal file
22
open-sse/utils/cors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* CORS configuration for open-sse handlers.
|
||||
*
|
||||
* Reads `CORS_ORIGIN` env var (default: "*") so that all handlers
|
||||
* use the same configurable origin. Equivalent to src/shared/utils/cors.ts
|
||||
* for the open-sse package boundary.
|
||||
*/
|
||||
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
|
||||
|
||||
export const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns just the origin header for merging into existing header objects.
|
||||
*/
|
||||
export function getCorsOrigin(): string {
|
||||
return CORS_ORIGIN;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "./cors.ts";
|
||||
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,7 @@ export function errorResponse(statusCode, message) {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -131,7 +132,11 @@ export async function parseUpstreamError(response, provider = null) {
|
||||
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
|
||||
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
|
||||
*/
|
||||
export function createErrorResult(statusCode: number, message: string, retryAfterMs: number | null = null) {
|
||||
export function createErrorResult(
|
||||
statusCode: number,
|
||||
message: string,
|
||||
retryAfterMs: number | null = null
|
||||
) {
|
||||
const result: Record<string, any> = {
|
||||
success: false,
|
||||
status: statusCode,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCorsOrigin } from "./cors.ts";
|
||||
// Transform OpenAI SSE stream to Ollama JSON lines format
|
||||
export function transformToOllama(response, model) {
|
||||
let buffer = "";
|
||||
@@ -85,6 +86,9 @@ export function transformToOllama(response, model) {
|
||||
});
|
||||
|
||||
return new Response(response.body.pipeThrough(transform), {
|
||||
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" },
|
||||
headers: {
|
||||
"Content-Type": "application/x-ndjson",
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
354
package-lock.json
generated
354
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
@@ -58,7 +58,8 @@
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -2489,15 +2490,79 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
|
||||
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
||||
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.55.0",
|
||||
"@typescript-eslint/types": "^8.55.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/type-utils": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
|
||||
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.56.0",
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2512,14 +2577,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
|
||||
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
|
||||
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0"
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2530,9 +2595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2546,10 +2611,35 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
|
||||
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
|
||||
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2561,16 +2651,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
|
||||
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
|
||||
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.55.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/project-service": "8.56.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^9.0.5",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2627,15 +2717,17 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
|
||||
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
|
||||
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2643,6 +2735,41 @@
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
|
||||
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
|
||||
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
@@ -4725,16 +4852,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -4753,133 +4870,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/typescript-eslint": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
|
||||
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.55.0",
|
||||
"@typescript-eslint/parser": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/type-utils": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
|
||||
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
|
||||
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||
@@ -9615,6 +9605,30 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -53,7 +53,7 @@
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --test tests/unit/security-fase01.test.mjs",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 30 --functions 30 --branches 30 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:all": "npm run test:unit && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
@@ -102,7 +102,8 @@
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,mjs}": [
|
||||
|
||||
@@ -158,10 +158,10 @@ export default function HomePageClient({ machineId }) {
|
||||
<span className="font-semibold">1. Create API key</span>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Go to{" "}
|
||||
<Link href="/dashboard/settings" className="text-primary hover:underline">
|
||||
Settings
|
||||
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
|
||||
Endpoint
|
||||
</Link>{" "}
|
||||
→ API Keys. Generate one key per environment.
|
||||
→ Registered Keys. Generate one key per environment.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
240
src/app/(dashboard)/dashboard/audit-log/page.tsx
Normal file
240
src/app/(dashboard)/dashboard/audit-log/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Audit Log Viewer — P-2
|
||||
*
|
||||
* Dashboard page for viewing administrative audit log entries.
|
||||
* Fetches from /api/compliance/audit-log with filter support.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface AuditEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string | null;
|
||||
details: any;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actionFilter) params.set("action", actionFilter);
|
||||
if (actorFilter) params.set("actor", actorFilter);
|
||||
params.set("limit", String(PAGE_SIZE + 1));
|
||||
params.set("offset", String(offset));
|
||||
|
||||
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: AuditEntry[] = await res.json();
|
||||
|
||||
setHasMore(data.length > PAGE_SIZE);
|
||||
setEntries(data.slice(0, PAGE_SIZE));
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch audit log");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actionFilter, actorFilter, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setOffset(0);
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
};
|
||||
|
||||
const actionBadgeColor = (action: string) => {
|
||||
if (action.includes("delete") || action.includes("remove"))
|
||||
return "bg-red-500/15 text-red-400 border-red-500/20";
|
||||
if (action.includes("create") || action.includes("add"))
|
||||
return "bg-green-500/15 text-green-400 border-green-500/20";
|
||||
if (action.includes("update") || action.includes("change"))
|
||||
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
|
||||
if (action.includes("login") || action.includes("auth"))
|
||||
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
|
||||
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">
|
||||
Audit Log
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||
Administrative actions and security events
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchEntries}
|
||||
disabled={loading}
|
||||
aria-label="Refresh audit log"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
|
||||
role="search"
|
||||
aria-label="Filter audit log entries"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action..."
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label="Filter by action type"
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by actor..."
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label="Filter by actor"
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Action
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Actor
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Target
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Details
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
IP
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
No audit log entries found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-main)]">
|
||||
{entry.actor}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
|
||||
{entry.target || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
|
||||
{entry.details ? JSON.stringify(entry.details) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.ip_address || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Showing {entries.length} entries (offset {offset})
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={!hasMore}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import IPFilterSection from "./IPFilterSection";
|
||||
import SessionInfoCard from "./SessionInfoCard";
|
||||
|
||||
export default function SecurityTab() {
|
||||
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
|
||||
@@ -145,6 +146,7 @@ export default function SecurityTab() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<SessionInfoCard />
|
||||
<IPFilterSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Session Info Card — P-3
|
||||
*
|
||||
* Displays current session details and provides session management
|
||||
* controls (logout, clear sessions) within the Security settings tab.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface SessionInfo {
|
||||
authenticated: boolean;
|
||||
loginTime: string | null;
|
||||
sessionAge: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export default function SessionInfoCard() {
|
||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadSession() {
|
||||
// Build session info from client-side data
|
||||
const loginTime = sessionStorage.getItem("omniroute_login_time");
|
||||
const now = Date.now();
|
||||
|
||||
let sessionAge = "Unknown";
|
||||
if (loginTime) {
|
||||
const elapsed = now - parseInt(loginTime, 10);
|
||||
const hours = Math.floor(elapsed / 3600000);
|
||||
const minutes = Math.floor((elapsed % 3600000) / 60000);
|
||||
sessionAge = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
let authenticated = false;
|
||||
try {
|
||||
const res = await fetch("/api/auth/status", {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
authenticated = data.authenticated === true;
|
||||
}
|
||||
} catch {
|
||||
// Keep unauthenticated fallback on network errors.
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setSession({
|
||||
authenticated,
|
||||
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
|
||||
sessionAge,
|
||||
ipAddress: "—", // Server-side only
|
||||
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
sessionStorage.removeItem("omniroute_login_time");
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
window.location.href = "/";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearStorage = () => {
|
||||
if (confirm("Clear all local data? This will reset your preferences.")) {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="animate-pulse h-32 bg-black/5 dark:bg-white/5 rounded-lg" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
person
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Session</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
|
||||
<div className="flex justify-between items-center text-sm" role="listitem">
|
||||
<span className="text-text-muted">Status</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${session?.authenticated ? "bg-green-500" : "bg-yellow-500"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{session?.authenticated ? "Authenticated" : "Guest"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session?.loginTime && (
|
||||
<div className="flex justify-between items-center text-sm" role="listitem">
|
||||
<span className="text-text-muted">Login Time</span>
|
||||
<span className="font-mono text-xs">{session.loginTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm" role="listitem">
|
||||
<span className="text-text-muted">Session Age</span>
|
||||
<span className="font-mono text-xs">{session?.sessionAge}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-sm" role="listitem">
|
||||
<span className="text-text-muted">Browser</span>
|
||||
<span className="font-mono text-xs truncate max-w-[200px]">{session?.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4 pt-4 border-t border-border/50">
|
||||
<Button variant="secondary" onClick={handleClearStorage}>
|
||||
Clear Local Data
|
||||
</Button>
|
||||
{session?.authenticated && (
|
||||
<Button variant="danger" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -184,8 +184,10 @@ export default function EvalsTab() {
|
||||
|
||||
// Count total cases and unique models across all suites
|
||||
const totalCases = suites.reduce((sum, s) => sum + (s.cases?.length || s.caseCount || 0), 0);
|
||||
const uniqueModels = [
|
||||
...new Set(suites.flatMap((s) => (s.cases || []).map((c) => c.model).filter(Boolean))),
|
||||
const uniqueModels: string[] = [
|
||||
...new Set(
|
||||
suites.flatMap((s: any) => (s.cases || []).map((c: any) => c.model)).filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
@@ -393,8 +395,8 @@ export default function EvalsTab() {
|
||||
const caseCount = suite.cases?.length || suite.caseCount || 0;
|
||||
|
||||
// Count unique models in this suite
|
||||
const suiteModels = [
|
||||
...new Set((suite.cases || []).map((c) => c.model).filter(Boolean)),
|
||||
const suiteModels: string[] = [
|
||||
...new Set<string>((suite.cases || []).map((c: any) => c.model).filter(Boolean)),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
23
src/app/api/auth/status/route.ts
Normal file
23
src/app/api/auth/status/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
const SECRET = process.env.JWT_SECRET
|
||||
? new TextEncoder().encode(process.env.JWT_SECRET)
|
||||
: null;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
|
||||
if (!token || !SECRET) {
|
||||
return NextResponse.json({ authenticated: false });
|
||||
}
|
||||
|
||||
await jwtVerify(token, SECRET);
|
||||
return NextResponse.json({ authenticated: true });
|
||||
} catch {
|
||||
return NextResponse.json({ authenticated: false });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { ollamaModels } from "@omniroute/open-sse/config/ollamaModels.ts";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts";
|
||||
@@ -15,7 +16,7 @@ async function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { parseSpeechModel } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { callCloudWithMachineId } from "@/shared/utils/cloud";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
@@ -26,7 +27,7 @@ function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { parseImageModel, getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
@@ -12,7 +13,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
@@ -92,15 +93,15 @@ export async function POST(request) {
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: result.status,
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
|
||||
@@ -20,7 +21,7 @@ async function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb";
|
||||
@@ -82,7 +83,7 @@ function buildAliasMaps() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
@@ -298,7 +299,7 @@ export async function GET() {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
@@ -19,7 +20,7 @@ async function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
@@ -11,7 +12,7 @@ import * as log from "@/sse/utils/logger";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -12,7 +13,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
@@ -79,15 +80,15 @@ export async function POST(request, { params }) {
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: result.status,
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
@@ -10,7 +11,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
|
||||
@@ -14,7 +15,7 @@ async function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
|
||||
@@ -20,7 +21,7 @@ async function ensureInitialized() {
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
|
||||
/**
|
||||
@@ -6,7 +7,7 @@ import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function DocsPage() {
|
||||
<li className="rounded-lg border border-border p-3 bg-bg">
|
||||
<span className="font-semibold">2. Create API key</span>
|
||||
<p className="text-text-muted mt-1">
|
||||
Go to Settings → API Keys. Generate one key per environment.
|
||||
Go to Endpoint → Registered Keys. Generate one key per environment.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-lg border border-border p-3 bg-bg">
|
||||
|
||||
64
src/app/error.tsx
Normal file
64
src/app/error.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Server Error Page — P-1
|
||||
*
|
||||
* Per-page error boundary for unrecoverable errors within the
|
||||
* dashboard layout. Falls back to global-error.tsx if this fails.
|
||||
*/
|
||||
|
||||
interface ErrorProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function Error({ error, reset }: ErrorProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[60vh] p-6 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="text-[64px] mb-4" aria-hidden="true">
|
||||
🔧
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]">
|
||||
Internal Server Error
|
||||
</h1>
|
||||
<p className="text-[15px] text-[var(--color-text-muted)] max-w-[400px] leading-relaxed mb-2">
|
||||
Something went wrong while processing your request. Our team has been
|
||||
notified and is working on a fix.
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
|
||||
aria-label="Error details"
|
||||
>
|
||||
{error.message}
|
||||
{error.stack && `\n\n${error.stack}`}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
aria-label="Retry loading the page"
|
||||
className="px-6 py-2.5 rounded-lg text-white text-sm font-semibold cursor-pointer transition-all duration-200 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
aria-label="Return to dashboard"
|
||||
>
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,29 +6,39 @@
|
||||
* Root-level error boundary for unrecoverable errors.
|
||||
* This is the last resort — catches errors that the per-page
|
||||
* error.js boundaries don't handle.
|
||||
* Styled with TailwindCSS 4 (Phase 7.3).
|
||||
*/
|
||||
|
||||
export default function GlobalError({ error, reset }) {
|
||||
interface GlobalErrorProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
||||
return (
|
||||
<html>
|
||||
<html lang="en">
|
||||
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-[#0a0a0f] text-[#e0e0e0] font-[system-ui,-apple-system,sans-serif] text-center m-0">
|
||||
<div className="text-[64px] mb-4">⚠️</div>
|
||||
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-[15px] text-[#888] max-w-[400px] leading-relaxed mb-6">
|
||||
An unexpected error occurred. This has been logged and our team will investigate.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6">
|
||||
{error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
|
||||
<div className="text-[64px] mb-4" aria-hidden="true">⚠️</div>
|
||||
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-[15px] text-[#888] max-w-[400px] leading-relaxed mb-6">
|
||||
An unexpected error occurred. This has been logged and our team will investigate.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
|
||||
aria-label="Error details"
|
||||
>
|
||||
{error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
aria-label="Retry loading the page"
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6] focus:outline-2 focus:outline-offset-2 focus:outline-[#6366f1]"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
--color-primary: #e54d5e;
|
||||
--color-primary-hover: #c93d4e;
|
||||
|
||||
/* Accent - Indigo/Violet (buttons, links, gradients) */
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #8b5cf6;
|
||||
--color-accent-light: #a855f7;
|
||||
|
||||
/* Semantic */
|
||||
--color-error: #ef4444;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
/* Traffic lights */
|
||||
--color-traffic-red: #ff5f56;
|
||||
--color-traffic-yellow: #ffbd2e;
|
||||
--color-traffic-green: #27c93f;
|
||||
|
||||
/* Light theme */
|
||||
--color-bg: #f9f9fb;
|
||||
--color-bg-alt: #f0f0f5;
|
||||
@@ -45,6 +60,16 @@
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-hover: var(--color-primary-hover);
|
||||
|
||||
/* Accent */
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-hover: var(--color-accent-hover);
|
||||
--color-accent-light: var(--color-accent-light);
|
||||
|
||||
/* Semantic */
|
||||
--color-error: var(--color-error);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
|
||||
/* Auto-switch colors (use CSS variables from :root/.dark) */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-surface: var(--color-surface);
|
||||
@@ -77,6 +102,30 @@
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Focus Indicators (U-3) ── */
|
||||
:root {
|
||||
--focus-ring: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent);
|
||||
}
|
||||
|
||||
/* Visible focus ring on keyboard navigation only */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Utility class for custom focus ring */
|
||||
.focus-ring:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Remove focus ring from mouse clicks */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
@@ -259,13 +308,13 @@ button .material-symbols-outlined,
|
||||
}
|
||||
|
||||
.traffic-light.red {
|
||||
background: #ff5f56;
|
||||
background: var(--color-traffic-red);
|
||||
}
|
||||
|
||||
.traffic-light.yellow {
|
||||
background: #ffbd2e;
|
||||
background: var(--color-traffic-yellow);
|
||||
}
|
||||
|
||||
.traffic-light.green {
|
||||
background: #27c93f;
|
||||
background: var(--color-traffic-green);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function LoginPage() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem("omniroute_login_time", String(Date.now()));
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} else {
|
||||
|
||||
@@ -4,24 +4,36 @@
|
||||
* Custom Not Found Page — FASE-04 Error Handling
|
||||
*
|
||||
* Displayed when a user navigates to a non-existent route.
|
||||
* Styled with TailwindCSS 4 (Phase 7.3).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center">
|
||||
<div className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-[#6366f1] via-[#8b5cf6] to-[#a855f7] bg-clip-text text-transparent">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center"
|
||||
role="main"
|
||||
aria-labelledby="not-found-title"
|
||||
>
|
||||
<div
|
||||
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-[#6366f1] via-[#8b5cf6] to-[#a855f7] bg-clip-text text-transparent"
|
||||
aria-hidden="true"
|
||||
>
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-2">Page not found</h1>
|
||||
<h1
|
||||
id="not-found-title"
|
||||
className="text-2xl font-semibold mb-2"
|
||||
>
|
||||
Page not found
|
||||
</h1>
|
||||
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
|
||||
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6] focus:outline-2 focus:outline-offset-2 focus:outline-[#6366f1]"
|
||||
aria-label="Return to dashboard"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Combo Resolver — FASE-09 Domain Extraction (T-46)
|
||||
*
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* @module domain/costRules
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
saveBudget,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* @module domain/fallbackPolicy
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
saveFallbackChain,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Lockout Policy — FASE-09 Domain Extraction (T-46)
|
||||
*
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* @module domain/modelAvailability
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} UnavailableEntry
|
||||
|
||||
16
src/instrumentation.ts
Normal file
16
src/instrumentation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook
|
||||
*
|
||||
* Called once when the server starts (both dev and production).
|
||||
* Used to initialize graceful shutdown handlers.
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server (not during build or in Edge runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
|
||||
initGracefulShutdown();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
* @module lib/compliance
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { getDbInstance } from "../db/core";
|
||||
|
||||
|
||||
122
src/lib/container.ts
Normal file
122
src/lib/container.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Simple DI Container — Factory-pattern service locator
|
||||
*
|
||||
* Provides a lightweight dependency injection container using factory
|
||||
* functions (no heavy frameworks). Services are lazily instantiated
|
||||
* and cached as singletons.
|
||||
*
|
||||
* Usage:
|
||||
* import { container } from '@/lib/container';
|
||||
* const settings = container.resolve('settings');
|
||||
*
|
||||
* Registration:
|
||||
* container.register('myService', () => new MyService());
|
||||
*
|
||||
* @module lib/container
|
||||
*/
|
||||
|
||||
import { evaluateFirstAllowed, evaluateRequest, PolicyEngine } from "../domain/policyEngine";
|
||||
import { getDbInstance } from "./db/core";
|
||||
import {
|
||||
decrypt,
|
||||
decryptConnectionFields,
|
||||
encrypt,
|
||||
encryptConnectionFields,
|
||||
} from "./db/encryption";
|
||||
import { getSettings } from "./localDb";
|
||||
import { getCircuitBreaker } from "../shared/utils/circuitBreaker";
|
||||
import { recordTelemetry, RequestTelemetry } from "../shared/utils/requestTelemetry";
|
||||
|
||||
type Factory<T = any> = () => T;
|
||||
|
||||
class Container {
|
||||
private _factories = new Map<string, Factory>();
|
||||
private _instances = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Register a factory for a service. Does NOT instantiate until resolve().
|
||||
*/
|
||||
register<T>(name: string, factory: Factory<T>): void {
|
||||
this._factories.set(name, factory);
|
||||
// Clear cached instance if re-registering (useful for testing)
|
||||
this._instances.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a service by name. Lazy-creates via factory on first call,
|
||||
* then returns the cached singleton.
|
||||
*/
|
||||
resolve<T = any>(name: string): T {
|
||||
if (this._instances.has(name)) {
|
||||
return this._instances.get(name) as T;
|
||||
}
|
||||
|
||||
const factory = this._factories.get(name);
|
||||
if (!factory) {
|
||||
throw new Error(`[Container] No factory registered for "${name}"`);
|
||||
}
|
||||
|
||||
const instance = factory();
|
||||
this._instances.set(name, instance);
|
||||
return instance as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is registered (factory exists).
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this._factories.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered service names.
|
||||
*/
|
||||
list(): string[] {
|
||||
return Array.from(this._factories.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all factories and instances (for testing).
|
||||
*/
|
||||
reset(): void {
|
||||
this._factories.clear();
|
||||
this._instances.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton container instance ──
|
||||
export const container = new Container();
|
||||
|
||||
// ── Default registrations ──
|
||||
// Services are still lazily instantiated on first resolve().
|
||||
|
||||
container.register("settings", () => {
|
||||
return { get: getSettings };
|
||||
});
|
||||
|
||||
container.register("db", () => {
|
||||
return getDbInstance();
|
||||
});
|
||||
|
||||
container.register("encryption", () => {
|
||||
return {
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptConnectionFields,
|
||||
decryptConnectionFields,
|
||||
};
|
||||
});
|
||||
|
||||
container.register("policyEngine", () => {
|
||||
return { evaluateRequest, evaluateFirstAllowed, PolicyEngine };
|
||||
});
|
||||
|
||||
container.register("circuitBreaker", () => {
|
||||
return { get: getCircuitBreaker };
|
||||
});
|
||||
|
||||
container.register("telemetry", () => {
|
||||
return { RequestTelemetry, recordTelemetry };
|
||||
});
|
||||
|
||||
export default container;
|
||||
@@ -8,6 +8,7 @@ import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";
|
||||
import { runMigrations } from "./migrationRunner";
|
||||
|
||||
// ──────────────── Environment Detection ────────────────
|
||||
|
||||
@@ -352,6 +353,20 @@ export function getDbInstance() {
|
||||
_db.exec(SCHEMA_SQL);
|
||||
ensureProviderConnectionsColumns(_db);
|
||||
|
||||
// ── Versioned Migrations ──
|
||||
// Auto-seed 001 as applied (the inline SCHEMA_SQL already created these tables)
|
||||
// then run any new migrations (002+)
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
|
||||
VALUES ('001', 'initial_schema');
|
||||
`);
|
||||
runMigrations(_db);
|
||||
|
||||
// Auto-migrate from db.json if exists
|
||||
if (JSON_DB_FILE && fs.existsSync(JSON_DB_FILE)) {
|
||||
migrateFromJson(_db, JSON_DB_FILE);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Field-Level Encryption — AES-256-GCM
|
||||
*
|
||||
@@ -7,8 +6,6 @@
|
||||
*
|
||||
* If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode
|
||||
* (stores plaintext for development convenience).
|
||||
*
|
||||
* @module lib/db/encryption
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
||||
@@ -18,15 +15,22 @@ const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const PREFIX = "enc:v1:";
|
||||
|
||||
/** @type {Buffer|null} */
|
||||
let _derivedKey = null;
|
||||
let _derivedKey: Buffer | null = null;
|
||||
|
||||
/** Connection object with potentially encrypted credential fields. */
|
||||
export interface ConnectionFields {
|
||||
apiKey?: string | null;
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
idToken?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 256-bit key from the env secret using scrypt.
|
||||
* Returns null if no encryption key is configured.
|
||||
* @returns {Buffer|null}
|
||||
*/
|
||||
function getKey() {
|
||||
function getKey(): Buffer | null {
|
||||
if (_derivedKey !== null) return _derivedKey;
|
||||
|
||||
const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
@@ -38,21 +42,16 @@ function getKey() {
|
||||
return _derivedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is enabled.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEncryptionEnabled() {
|
||||
/** Check if encryption is enabled. */
|
||||
export function isEncryptionEnabled(): boolean {
|
||||
return !!process.env.STORAGE_ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string. Returns ciphertext with prefix.
|
||||
* If encryption is not configured, returns plaintext unchanged.
|
||||
* @param {string|null|undefined} plaintext
|
||||
* @returns {string|null|undefined}
|
||||
*/
|
||||
export function encrypt(plaintext) {
|
||||
export function encrypt(plaintext: string | null | undefined): string | null | undefined {
|
||||
if (!plaintext || typeof plaintext !== "string") return plaintext;
|
||||
|
||||
const key = getKey();
|
||||
@@ -73,10 +72,8 @@ export function encrypt(plaintext) {
|
||||
|
||||
/**
|
||||
* Decrypt a ciphertext string. If not encrypted (no prefix), returns as-is.
|
||||
* @param {string|null|undefined} ciphertext
|
||||
* @returns {string|null|undefined}
|
||||
*/
|
||||
export function decrypt(ciphertext) {
|
||||
export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
|
||||
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
|
||||
|
||||
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
|
||||
@@ -108,18 +105,18 @@ export function decrypt(ciphertext) {
|
||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
return decrypted;
|
||||
} catch (err) {
|
||||
console.error("[Encryption] Decryption failed:", err.message);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[Encryption] Decryption failed:", message);
|
||||
return ciphertext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields in a connection object (mutates in-place).
|
||||
* @param {object} conn
|
||||
* @returns {object} The same object with encrypted fields
|
||||
* Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls.
|
||||
*/
|
||||
export function encryptConnectionFields(conn) {
|
||||
export function encryptConnectionFields(conn: any): any {
|
||||
if (!isEncryptionEnabled()) return conn;
|
||||
|
||||
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey);
|
||||
@@ -131,10 +128,9 @@ export function encryptConnectionFields(conn) {
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a connection row (returns new object).
|
||||
* @param {object|null} row
|
||||
* @returns {object|null}
|
||||
* Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls.
|
||||
*/
|
||||
export function decryptConnectionFields(row) {
|
||||
export function decryptConnectionFields(row: any): any {
|
||||
if (!row) return row;
|
||||
if (!isEncryptionEnabled()) return row;
|
||||
|
||||
|
||||
126
src/lib/db/migrationRunner.ts
Normal file
126
src/lib/db/migrationRunner.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Migration Runner — Versioned SQL Migrations for SQLite
|
||||
*
|
||||
* Reads numbered `.sql` files from the migrations directory and applies
|
||||
* them sequentially, tracking applied versions in a `schema_migrations` table.
|
||||
*
|
||||
* Naming convention: `NNN_description.sql` (e.g., `001_initial_schema.sql`)
|
||||
*
|
||||
* All migrations run within a single transaction — all-or-nothing per file.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
|
||||
|
||||
/**
|
||||
* Ensure the schema_migrations tracking table exists.
|
||||
*/
|
||||
function ensureMigrationsTable(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all migration files sorted by version number.
|
||||
*/
|
||||
function getMigrationFiles(): Array<{ version: string; name: string; path: string }> {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((filename) => {
|
||||
const match = filename.match(/^(\d+)_(.+)\.sql$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
version: match[1],
|
||||
name: match[2],
|
||||
path: path.join(MIGRATIONS_DIR, filename),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ version: string; name: string; path: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of already-applied migration versions.
|
||||
*/
|
||||
function getAppliedVersions(db: Database.Database): Set<string> {
|
||||
const rows = db.prepare("SELECT version FROM _omniroute_migrations").all() as Array<{
|
||||
version: string;
|
||||
}>;
|
||||
return new Set(rows.map((r) => r.version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations in order.
|
||||
* Returns the number of migrations applied.
|
||||
*/
|
||||
export function runMigrations(db: Database.Database): number {
|
||||
ensureMigrationsTable(db);
|
||||
|
||||
const files = getMigrationFiles();
|
||||
const applied = getAppliedVersions(db);
|
||||
let count = 0;
|
||||
|
||||
for (const migration of files) {
|
||||
if (applied.has(migration.version)) continue;
|
||||
|
||||
const sql = fs.readFileSync(migration.path, "utf-8");
|
||||
|
||||
const applyMigration = db.transaction(() => {
|
||||
db.exec(sql);
|
||||
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
|
||||
migration.version,
|
||||
migration.name
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
applyMigration();
|
||||
count++;
|
||||
console.log(`[Migration] Applied: ${migration.version}_${migration.name}`);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Migration] FAILED: ${migration.version}_${migration.name} — ${message}`);
|
||||
throw err; // Re-throw to prevent DB from starting in inconsistent state
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
console.log(`[Migration] ${count} migration(s) applied successfully.`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration status for diagnostics.
|
||||
*/
|
||||
export function getMigrationStatus(db: Database.Database): {
|
||||
applied: Array<{ version: string; name: string; applied_at: string }>;
|
||||
pending: Array<{ version: string; name: string }>;
|
||||
} {
|
||||
ensureMigrationsTable(db);
|
||||
|
||||
const appliedRows = db
|
||||
.prepare("SELECT version, name, applied_at FROM _omniroute_migrations ORDER BY version")
|
||||
.all() as Array<{ version: string; name: string; applied_at: string }>;
|
||||
|
||||
const appliedVersions = new Set(appliedRows.map((r) => r.version));
|
||||
const allFiles = getMigrationFiles();
|
||||
const pending = allFiles.filter((f) => !appliedVersions.has(f.version));
|
||||
|
||||
return { applied: appliedRows, pending };
|
||||
}
|
||||
202
src/lib/db/migrations/001_initial_schema.sql
Normal file
202
src/lib/db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,202 @@
|
||||
-- 001_initial_schema.sql
|
||||
-- Initial schema for OmniRoute SQLite database.
|
||||
-- This migration is automatically marked as applied for existing databases
|
||||
-- since the schema was previously applied via CREATE TABLE IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
auth_type TEXT,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT,
|
||||
token_expires_at TEXT,
|
||||
scope TEXT,
|
||||
project_id TEXT,
|
||||
test_status TEXT,
|
||||
error_code TEXT,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_type TEXT,
|
||||
last_error_source TEXT,
|
||||
backoff_level INTEGER DEFAULT 0,
|
||||
rate_limited_until TEXT,
|
||||
health_check_interval INTEGER,
|
||||
last_health_check_at TEXT,
|
||||
last_tested TEXT,
|
||||
api_key TEXT,
|
||||
id_token TEXT,
|
||||
provider_specific_data TEXT,
|
||||
expires_in INTEGER,
|
||||
display_name TEXT,
|
||||
global_priority INTEGER,
|
||||
default_model TEXT,
|
||||
token_type TEXT,
|
||||
consecutive_use_count INTEGER DEFAULT 0,
|
||||
rate_limit_protection INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
prefix TEXT,
|
||||
api_type TEXT,
|
||||
base_url TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_value (
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (namespace, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS combos (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
machine_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ak_key ON api_keys(key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS db_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
connection_id TEXT,
|
||||
api_key_id TEXT,
|
||||
api_key_name TEXT,
|
||||
tokens_input INTEGER DEFAULT 0,
|
||||
tokens_output INTEGER DEFAULT 0,
|
||||
tokens_cache_read INTEGER DEFAULT 0,
|
||||
tokens_cache_creation INTEGER DEFAULT 0,
|
||||
tokens_reasoning INTEGER DEFAULT 0,
|
||||
status TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS call_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
status INTEGER,
|
||||
model TEXT,
|
||||
provider TEXT,
|
||||
account TEXT,
|
||||
connection_id TEXT,
|
||||
duration INTEGER DEFAULT 0,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
source_format TEXT,
|
||||
target_format TEXT,
|
||||
api_key_id TEXT,
|
||||
api_key_name TEXT,
|
||||
combo_name TEXT,
|
||||
request_body TEXT,
|
||||
response_body TEXT,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proxy_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT,
|
||||
proxy_type TEXT,
|
||||
proxy_host TEXT,
|
||||
proxy_port INTEGER,
|
||||
level TEXT,
|
||||
level_id TEXT,
|
||||
provider TEXT,
|
||||
target_url TEXT,
|
||||
public_ip TEXT,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
error TEXT,
|
||||
connection_id TEXT,
|
||||
combo_id TEXT,
|
||||
account TEXT,
|
||||
tls_fingerprint INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pl_timestamp ON proxy_logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_pl_status ON proxy_logs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_pl_provider ON proxy_logs(provider);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_fallback_chains (
|
||||
model TEXT PRIMARY KEY,
|
||||
chain TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_budgets (
|
||||
api_key_id TEXT PRIMARY KEY,
|
||||
daily_limit_usd REAL NOT NULL,
|
||||
monthly_limit_usd REAL DEFAULT 0,
|
||||
warning_threshold REAL DEFAULT 0.8
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_cost_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
api_key_id TEXT NOT NULL,
|
||||
cost REAL NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_lockout_state (
|
||||
identifier TEXT PRIMARY KEY,
|
||||
attempts TEXT NOT NULL,
|
||||
locked_until INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_circuit_breakers (
|
||||
name TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT 'CLOSED',
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
last_failure_time INTEGER,
|
||||
options TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semantic_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
signature TEXT NOT NULL UNIQUE,
|
||||
model TEXT NOT NULL,
|
||||
prompt_hash TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
tokens_saved INTEGER DEFAULT 0,
|
||||
hit_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_sig ON semantic_cache(signature);
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_model ON semantic_cache(model);
|
||||
241
src/lib/db/prompts.ts
Normal file
241
src/lib/db/prompts.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Prompt Template Versioning — L-6
|
||||
*
|
||||
* SQLite-backed prompt template storage with version tracking.
|
||||
* Each prompt has a unique `slug`, and every save creates a new version
|
||||
* (content-addressed via SHA-256 hash). Previous versions are retained
|
||||
* for rollback and audit.
|
||||
*
|
||||
* @module lib/db/prompts
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
// ── Schema (auto-created on first access) ──
|
||||
|
||||
const PROMPT_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
variables TEXT,
|
||||
description TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(slug, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_slug ON prompt_templates(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_active ON prompt_templates(slug, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_hash ON prompt_templates(content_hash);
|
||||
`;
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
function ensureSchema(): void {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
db.exec(PROMPT_SCHEMA);
|
||||
_initialized = true;
|
||||
} catch {
|
||||
// Schema creation is best-effort during build phase
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: number;
|
||||
slug: string;
|
||||
version: number;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
variables: string[] | null;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a prompt template. If the slug already exists and the content
|
||||
* has changed, a new version is created. If content is identical,
|
||||
* returns the existing version without duplicating.
|
||||
*/
|
||||
export function savePrompt(
|
||||
slug: string,
|
||||
content: string,
|
||||
options: { variables?: string[]; description?: string } = {}
|
||||
): PromptTemplate {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const hash = hashContent(content);
|
||||
|
||||
// Check if identical content already exists for this slug
|
||||
const existing = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND content_hash = ?")
|
||||
.get(slug, hash) as any;
|
||||
|
||||
if (existing) {
|
||||
return rowToPrompt(existing);
|
||||
}
|
||||
|
||||
// Deactivate previous active version
|
||||
db.prepare("UPDATE prompt_templates SET is_active = 0 WHERE slug = ? AND is_active = 1").run(
|
||||
slug
|
||||
);
|
||||
|
||||
// Get next version number
|
||||
const maxVersion = db
|
||||
.prepare("SELECT MAX(version) as max_v FROM prompt_templates WHERE slug = ?")
|
||||
.get(slug) as any;
|
||||
const nextVersion = (maxVersion?.max_v || 0) + 1;
|
||||
|
||||
// Insert new version
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO prompt_templates (slug, version, content, content_hash, variables, description, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`
|
||||
)
|
||||
.run(
|
||||
slug,
|
||||
nextVersion,
|
||||
content,
|
||||
hash,
|
||||
options.variables ? JSON.stringify(options.variables) : null,
|
||||
options.description || null
|
||||
);
|
||||
|
||||
return {
|
||||
id: Number(result.lastInsertRowid),
|
||||
slug,
|
||||
version: nextVersion,
|
||||
content,
|
||||
contentHash: hash,
|
||||
variables: options.variables || null,
|
||||
description: options.description || null,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active (latest) version of a prompt by slug.
|
||||
*/
|
||||
export function getActivePrompt(slug: string): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND is_active = 1")
|
||||
.get(slug) as any;
|
||||
return row ? rowToPrompt(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version of a prompt.
|
||||
*/
|
||||
export function getPromptVersion(slug: string, version: number): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version) as any;
|
||||
return row ? rowToPrompt(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all versions of a prompt (newest first).
|
||||
*/
|
||||
export function listPromptVersions(slug: string): PromptTemplate[] {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? ORDER BY version DESC")
|
||||
.all(slug) as any[];
|
||||
return rows.map(rowToPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all prompt slugs with their active version info.
|
||||
*/
|
||||
export function listPrompts(): Array<{ slug: string; activeVersion: number; totalVersions: number }> {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT slug,
|
||||
MAX(CASE WHEN is_active = 1 THEN version ELSE 0 END) as active_version,
|
||||
COUNT(*) as total_versions
|
||||
FROM prompt_templates
|
||||
GROUP BY slug
|
||||
ORDER BY slug`
|
||||
)
|
||||
.all() as any[];
|
||||
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
activeVersion: r.active_version,
|
||||
totalVersions: r.total_versions,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to a previous version (makes it the active one).
|
||||
*/
|
||||
export function rollbackPrompt(slug: string, version: number): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
|
||||
const target = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version) as any;
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const rollback = db.transaction(() => {
|
||||
db.prepare("UPDATE prompt_templates SET is_active = 0 WHERE slug = ?").run(slug);
|
||||
db.prepare("UPDATE prompt_templates SET is_active = 1 WHERE slug = ? AND version = ?").run(
|
||||
slug,
|
||||
version
|
||||
);
|
||||
});
|
||||
rollback();
|
||||
|
||||
return rowToPrompt({ ...target, is_active: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a prompt template by substituting variables.
|
||||
*/
|
||||
export function renderPrompt(slug: string, vars: Record<string, string> = {}): string | null {
|
||||
const prompt = getActivePrompt(slug);
|
||||
if (!prompt) return null;
|
||||
|
||||
let content = prompt.content;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── Internal ──
|
||||
|
||||
function rowToPrompt(row: any): PromptTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
version: row.version,
|
||||
content: row.content,
|
||||
contentHash: row.content_hash,
|
||||
variables: row.variables ? JSON.parse(row.variables) : null,
|
||||
description: row.description,
|
||||
isActive: row.is_active === 1,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
* @module lib/evals/evalRunner
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} EvalCase
|
||||
|
||||
275
src/lib/evals/scheduler.ts
Normal file
275
src/lib/evals/scheduler.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Eval Scheduler — L-7
|
||||
*
|
||||
* Cron-based scheduling for golden set evaluation runs.
|
||||
* Uses a simple interval timer (no external cron dependency).
|
||||
* Results are persisted to SQLite for trend tracking.
|
||||
*
|
||||
* @module lib/evals/scheduler
|
||||
*/
|
||||
|
||||
import { runSuite, listSuites, createScorecard, getSuite } from "./evalRunner";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ScheduledEval {
|
||||
suiteId: string;
|
||||
intervalMs: number;
|
||||
lastRunAt: number | null;
|
||||
nextRunAt: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface EvalRunResult {
|
||||
suiteId: string;
|
||||
suiteName: string;
|
||||
timestamp: number;
|
||||
passRate: number;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
results: any[];
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
const _schedules = new Map<string, ScheduledEval>();
|
||||
const _timers = new Map<string, NodeJS.Timer>();
|
||||
const _history: EvalRunResult[] = [];
|
||||
let _outputProvider: ((suiteId: string, caseId: string) => Promise<string>) | null = null;
|
||||
|
||||
// ── Configuration ──
|
||||
|
||||
/**
|
||||
* Set the output provider function — called to get actual LLM output
|
||||
* for each eval case. This decouples the scheduler from the chat pipeline.
|
||||
*
|
||||
* @param fn - Async function(suiteId, caseId) → actual output string
|
||||
*/
|
||||
export function setOutputProvider(
|
||||
fn: (suiteId: string, caseId: string) => Promise<string>
|
||||
): void {
|
||||
_outputProvider = fn;
|
||||
}
|
||||
|
||||
// ── Scheduling ──
|
||||
|
||||
/**
|
||||
* Schedule a suite to run at a fixed interval.
|
||||
*
|
||||
* @param suiteId - ID of a registered eval suite
|
||||
* @param intervalMs - Interval between runs in milliseconds (min 60000 = 1 min)
|
||||
*/
|
||||
export function schedule(suiteId: string, intervalMs: number): ScheduledEval {
|
||||
const safeInterval = Math.max(intervalMs, 60_000); // Min 1 minute
|
||||
const now = Date.now();
|
||||
|
||||
// Clear existing timer if re-scheduling
|
||||
if (_timers.has(suiteId)) {
|
||||
clearInterval(_timers.get(suiteId) as any);
|
||||
}
|
||||
|
||||
const entry: ScheduledEval = {
|
||||
suiteId,
|
||||
intervalMs: safeInterval,
|
||||
lastRunAt: null,
|
||||
nextRunAt: now + safeInterval,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
_schedules.set(suiteId, entry);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
executeScheduledRun(suiteId).catch((err) => {
|
||||
console.error(`[EvalScheduler] Failed to run suite ${suiteId}:`, err.message);
|
||||
});
|
||||
}, safeInterval);
|
||||
|
||||
_timers.set(suiteId, timer);
|
||||
|
||||
console.log(
|
||||
`[EvalScheduler] Scheduled "${suiteId}" every ${Math.round(safeInterval / 1000)}s`
|
||||
);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unschedule a suite.
|
||||
*/
|
||||
export function unschedule(suiteId: string): boolean {
|
||||
const timer = _timers.get(suiteId);
|
||||
if (timer) {
|
||||
clearInterval(timer as any);
|
||||
_timers.delete(suiteId);
|
||||
}
|
||||
return _schedules.delete(suiteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a scheduled suite without removing it.
|
||||
*/
|
||||
export function pause(suiteId: string): boolean {
|
||||
const entry = _schedules.get(suiteId);
|
||||
if (!entry) return false;
|
||||
entry.enabled = false;
|
||||
const timer = _timers.get(suiteId);
|
||||
if (timer) {
|
||||
clearInterval(timer as any);
|
||||
_timers.delete(suiteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused scheduled suite.
|
||||
*/
|
||||
export function resume(suiteId: string): boolean {
|
||||
const entry = _schedules.get(suiteId);
|
||||
if (!entry) return false;
|
||||
entry.enabled = true;
|
||||
return !!schedule(suiteId, entry.intervalMs);
|
||||
}
|
||||
|
||||
// ── Execution ──
|
||||
|
||||
/**
|
||||
* Execute a scheduled run for a suite.
|
||||
*/
|
||||
async function executeScheduledRun(suiteId: string): Promise<EvalRunResult | null> {
|
||||
const entry = _schedules.get(suiteId);
|
||||
if (!entry?.enabled) return null;
|
||||
|
||||
if (!_outputProvider) {
|
||||
console.warn(`[EvalScheduler] No output provider set — skipping ${suiteId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[EvalScheduler] Running suite: ${suiteId}`);
|
||||
|
||||
try {
|
||||
// Collect outputs for all cases in the suite
|
||||
const suites = listSuites();
|
||||
const suiteInfo = suites.find((s) => s.id === suiteId);
|
||||
if (!suiteInfo) {
|
||||
console.warn(`[EvalScheduler] Suite not found: ${suiteId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get outputs from provider
|
||||
const outputs: Record<string, string> = {};
|
||||
// We use the suite's cases to get the case IDs
|
||||
const suite = getSuite(suiteId);
|
||||
if (!suite?.cases) return null;
|
||||
|
||||
for (const evalCase of suite.cases) {
|
||||
try {
|
||||
outputs[evalCase.id] = await _outputProvider(suiteId, evalCase.id);
|
||||
} catch (err: any) {
|
||||
console.warn(`[EvalScheduler] Failed to get output for ${evalCase.id}: ${err.message}`);
|
||||
outputs[evalCase.id] = `[ERROR] ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Run evaluation
|
||||
const result = runSuite(suiteId, outputs);
|
||||
const now = Date.now();
|
||||
|
||||
const runResult: EvalRunResult = {
|
||||
suiteId: result.suiteId,
|
||||
suiteName: result.suiteName,
|
||||
timestamp: now,
|
||||
passRate: result.summary.passRate,
|
||||
total: result.summary.total,
|
||||
passed: result.summary.passed,
|
||||
failed: result.summary.failed,
|
||||
results: result.results,
|
||||
};
|
||||
|
||||
// Update schedule state
|
||||
entry.lastRunAt = now;
|
||||
entry.nextRunAt = now + entry.intervalMs;
|
||||
|
||||
// Store in history
|
||||
_history.push(runResult);
|
||||
// Keep last 100 runs
|
||||
if (_history.length > 100) _history.shift();
|
||||
|
||||
console.log(
|
||||
`[EvalScheduler] ${suiteId}: ${result.summary.passed}/${result.summary.total} passed (${(result.summary.passRate * 100).toFixed(1)}%)`
|
||||
);
|
||||
|
||||
return runResult;
|
||||
} catch (err: any) {
|
||||
console.error(`[EvalScheduler] Error running ${suiteId}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a suite immediately (outside of schedule).
|
||||
*/
|
||||
export async function runNow(suiteId: string): Promise<EvalRunResult | null> {
|
||||
const entry = _schedules.get(suiteId) || {
|
||||
suiteId,
|
||||
intervalMs: 0,
|
||||
lastRunAt: null,
|
||||
nextRunAt: 0,
|
||||
enabled: true,
|
||||
};
|
||||
_schedules.set(suiteId, entry);
|
||||
return executeScheduledRun(suiteId);
|
||||
}
|
||||
|
||||
// ── Query ──
|
||||
|
||||
/**
|
||||
* Get all scheduled suites and their status.
|
||||
*/
|
||||
export function getSchedules(): ScheduledEval[] {
|
||||
return Array.from(_schedules.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get run history for a suite (newest first).
|
||||
*/
|
||||
export function getHistory(suiteId?: string): EvalRunResult[] {
|
||||
const filtered = suiteId ? _history.filter((r) => r.suiteId === suiteId) : _history;
|
||||
return [...filtered].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scorecard across all recent runs.
|
||||
*/
|
||||
export function getScorecard(): ReturnType<typeof createScorecard> | null {
|
||||
if (_history.length === 0) return null;
|
||||
|
||||
// Get latest run per suite
|
||||
const latestBySuite = new Map<string, any>();
|
||||
for (const run of _history) {
|
||||
latestBySuite.set(run.suiteId, run);
|
||||
}
|
||||
|
||||
// Build scorecard from latest runs
|
||||
const runs = Array.from(latestBySuite.values()).map((r) => ({
|
||||
suiteId: r.suiteId,
|
||||
suiteName: r.suiteName,
|
||||
results: r.results,
|
||||
summary: { total: r.total, passed: r.passed, failed: r.failed, passRate: r.passRate },
|
||||
}));
|
||||
|
||||
return createScorecard(runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled evaluations and clear state.
|
||||
*/
|
||||
export function stopAll(): void {
|
||||
for (const timer of _timers.values()) {
|
||||
clearInterval(timer as any);
|
||||
}
|
||||
_timers.clear();
|
||||
_schedules.clear();
|
||||
_history.length = 0;
|
||||
_outputProvider = null;
|
||||
}
|
||||
124
src/lib/gracefulShutdown.ts
Normal file
124
src/lib/gracefulShutdown.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Graceful Shutdown — E-2 Critical Fix
|
||||
*
|
||||
* Handles SIGTERM / SIGINT to drain in-flight requests before exit.
|
||||
* Critical for Docker containers and Kubernetes pods where hard kills
|
||||
* can drop active SSE streams.
|
||||
*
|
||||
* Usage:
|
||||
* import { initGracefulShutdown } from "@/lib/gracefulShutdown";
|
||||
* initGracefulShutdown();
|
||||
*
|
||||
* @module lib/gracefulShutdown
|
||||
*/
|
||||
|
||||
/** Whether we are currently shutting down */
|
||||
let isShuttingDown = false;
|
||||
|
||||
/** Number of in-flight requests being tracked */
|
||||
let activeRequests = 0;
|
||||
|
||||
/** Grace period before forced exit (default 30s, configurable) */
|
||||
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10);
|
||||
|
||||
/**
|
||||
* Check if the server is currently shutting down.
|
||||
* Route handlers can use this to reject new requests.
|
||||
*/
|
||||
export function isDraining(): boolean {
|
||||
return isShuttingDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a new in-flight request. Call `done()` when it completes.
|
||||
* Returns a done callback.
|
||||
*/
|
||||
export function trackRequest(): () => void {
|
||||
activeRequests++;
|
||||
let called = false;
|
||||
return () => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
activeRequests--;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active request count (for monitoring/health endpoints).
|
||||
*/
|
||||
export function getActiveRequestCount(): number {
|
||||
return activeRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all in-flight requests to complete, with timeout.
|
||||
*/
|
||||
async function waitForDrain(): Promise<void> {
|
||||
const start = Date.now();
|
||||
const CHECK_INTERVAL_MS = 250;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (activeRequests <= 0) {
|
||||
console.log("[Shutdown] All in-flight requests drained.");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) {
|
||||
console.warn(
|
||||
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.`
|
||||
);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`);
|
||||
setTimeout(check, CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup: close DB connections, flush logs.
|
||||
*/
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
// Close SQLite database — import dynamically to avoid circular deps
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
if (db && typeof db.close === "function") {
|
||||
db.close();
|
||||
console.log("[Shutdown] SQLite database closed.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize graceful shutdown handlers.
|
||||
* Should be called once during server startup.
|
||||
*/
|
||||
export function initGracefulShutdown(): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) return; // Prevent double-shutdown
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
console.log("[Shutdown] Graceful shutdown handlers registered.");
|
||||
}
|
||||
179
src/lib/piiSanitizer.ts
Normal file
179
src/lib/piiSanitizer.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Output PII Sanitization — L-3
|
||||
*
|
||||
* Scans LLM response text for PII patterns and optionally redacts them.
|
||||
* This is the OUTPUT-side counterpart to the input sanitizer.
|
||||
* Configurable via environment variables:
|
||||
*
|
||||
* PII_RESPONSE_SANITIZATION=true|false (default: false)
|
||||
* PII_RESPONSE_SANITIZATION_MODE=redact|warn|block (default: redact)
|
||||
*
|
||||
* @module lib/piiSanitizer
|
||||
*/
|
||||
|
||||
// ── Configuration ──
|
||||
|
||||
const isEnabled = () => process.env.PII_RESPONSE_SANITIZATION === "true";
|
||||
const getMode = (): "redact" | "warn" | "block" =>
|
||||
(process.env.PII_RESPONSE_SANITIZATION_MODE as "redact" | "warn" | "block") || "redact";
|
||||
|
||||
// ── PII Patterns ──
|
||||
|
||||
interface PIIPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
replacement: string;
|
||||
severity: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
const PII_PATTERNS: PIIPattern[] = [
|
||||
{
|
||||
name: "email",
|
||||
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
||||
replacement: "[EMAIL_REDACTED]",
|
||||
severity: "medium",
|
||||
},
|
||||
{
|
||||
name: "ssn",
|
||||
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
||||
replacement: "[SSN_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "credit_card",
|
||||
regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
||||
replacement: "[CC_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "phone_us",
|
||||
regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
||||
replacement: "[PHONE_REDACTED]",
|
||||
severity: "medium",
|
||||
},
|
||||
{
|
||||
name: "phone_br",
|
||||
regex: /\b(?:\+?55[-.\s]?)?\(?\d{2}\)?[-.\s]?\d{4,5}[-.\s]?\d{4}\b/g,
|
||||
replacement: "[PHONE_REDACTED]",
|
||||
severity: "medium",
|
||||
},
|
||||
{
|
||||
name: "cpf",
|
||||
regex: /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/g,
|
||||
replacement: "[CPF_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "cnpj",
|
||||
regex: /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/g,
|
||||
replacement: "[CNPJ_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "ip_address",
|
||||
regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
||||
replacement: "[IP_REDACTED]",
|
||||
severity: "low",
|
||||
},
|
||||
{
|
||||
name: "aws_key",
|
||||
regex: /\bAKIA[0-9A-Z]{16}\b/g,
|
||||
replacement: "[AWS_KEY_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "api_key_generic",
|
||||
regex: /\b(?:sk|pk|api|key|token)[_-][a-zA-Z0-9]{20,}\b/gi,
|
||||
replacement: "[API_KEY_REDACTED]",
|
||||
severity: "high",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export interface SanitizeResult {
|
||||
text: string;
|
||||
detections: Array<{
|
||||
pattern: string;
|
||||
count: number;
|
||||
severity: string;
|
||||
}>;
|
||||
redacted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan and optionally redact PII from LLM response text.
|
||||
*/
|
||||
export function sanitizePII(text: string): SanitizeResult {
|
||||
if (!isEnabled() || !text || typeof text !== "string") {
|
||||
return { text, detections: [], redacted: false };
|
||||
}
|
||||
|
||||
const mode = getMode();
|
||||
const detections: SanitizeResult["detections"] = [];
|
||||
let sanitized = text;
|
||||
|
||||
for (const pattern of PII_PATTERNS) {
|
||||
// Reset lastIndex for global regexes
|
||||
pattern.regex.lastIndex = 0;
|
||||
const matches = text.match(pattern.regex);
|
||||
if (matches && matches.length > 0) {
|
||||
detections.push({
|
||||
pattern: pattern.name,
|
||||
count: matches.length,
|
||||
severity: pattern.severity,
|
||||
});
|
||||
|
||||
if (mode === "redact") {
|
||||
pattern.regex.lastIndex = 0;
|
||||
sanitized = sanitized.replace(pattern.regex, pattern.replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (detections.length > 0 && mode === "warn") {
|
||||
console.warn(
|
||||
`[PII] Detected PII in response: ${detections.map((d) => `${d.pattern}(${d.count})`).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
text: mode === "redact" ? sanitized : text,
|
||||
detections,
|
||||
redacted: mode === "redact" && detections.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a streaming chunk (text content only).
|
||||
*/
|
||||
export function sanitizePIIChunk(chunk: string): string {
|
||||
if (!isEnabled()) return chunk;
|
||||
const { text } = sanitizePII(chunk);
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize PII in a full response object (OpenAI-compatible format).
|
||||
*/
|
||||
export function sanitizePIIResponse(response: any): any {
|
||||
if (!isEnabled() || !response) return response;
|
||||
|
||||
try {
|
||||
const choices = response.choices || [];
|
||||
for (const choice of choices) {
|
||||
if (choice.message?.content) {
|
||||
const result = sanitizePII(choice.message.content);
|
||||
choice.message.content = result.text;
|
||||
}
|
||||
if (choice.delta?.content) {
|
||||
const result = sanitizePII(choice.delta.content);
|
||||
choice.delta.content = result.text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail open — don't break the response
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
214
src/lib/plugins/index.ts
Normal file
214
src/lib/plugins/index.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Plugin/Middleware Architecture — L-8
|
||||
*
|
||||
* Pre/post hooks on the request pipeline. Plugins are registered
|
||||
* with a priority (lower = runs first) and can intercept requests
|
||||
* before they reach the chat handler or modify responses after.
|
||||
*
|
||||
* Lifecycle:
|
||||
* onRequest → runs BEFORE chat handler (can block/modify request)
|
||||
* onResponse → runs AFTER chat handler (can modify/log response)
|
||||
* onError → runs on handler errors (can recover or re-throw)
|
||||
*
|
||||
* @module lib/plugins
|
||||
*/
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface PluginContext {
|
||||
/** Unique request ID */
|
||||
requestId: string;
|
||||
/** Request body (parsed JSON) */
|
||||
body: any;
|
||||
/** Model string */
|
||||
model: string;
|
||||
/** Provider (if resolved) */
|
||||
provider?: string;
|
||||
/** API key info */
|
||||
apiKeyInfo?: any;
|
||||
/** Arbitrary metadata plugins can share */
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PluginResult {
|
||||
/** If true, stop processing further plugins and return immediately */
|
||||
blocked?: boolean;
|
||||
/** Optional response to return if blocked */
|
||||
response?: any;
|
||||
/** Modified body (if any) */
|
||||
body?: any;
|
||||
/** Modified metadata */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
/** Unique plugin name */
|
||||
name: string;
|
||||
/** Priority (lower = runs first, default 100) */
|
||||
priority?: number;
|
||||
/** Whether the plugin is enabled */
|
||||
enabled?: boolean;
|
||||
/** Called before the chat handler */
|
||||
onRequest?: (ctx: PluginContext) => Promise<PluginResult | void> | PluginResult | void;
|
||||
/** Called after the chat handler */
|
||||
onResponse?: (
|
||||
ctx: PluginContext,
|
||||
response: any
|
||||
) => Promise<any | void> | any | void;
|
||||
/** Called on handler error */
|
||||
onError?: (
|
||||
ctx: PluginContext,
|
||||
error: Error
|
||||
) => Promise<any | void> | any | void;
|
||||
}
|
||||
|
||||
// ── Registry ──
|
||||
|
||||
const _plugins: Plugin[] = [];
|
||||
|
||||
/**
|
||||
* Register a plugin. Plugins are sorted by priority on each registration.
|
||||
*/
|
||||
export function registerPlugin(plugin: Plugin): void {
|
||||
// Set defaults
|
||||
plugin.priority = plugin.priority ?? 100;
|
||||
plugin.enabled = plugin.enabled ?? true;
|
||||
|
||||
// Remove existing plugin with same name (re-registration)
|
||||
const idx = _plugins.findIndex((p) => p.name === plugin.name);
|
||||
if (idx !== -1) _plugins.splice(idx, 1);
|
||||
|
||||
_plugins.push(plugin);
|
||||
_plugins.sort((a, b) => (a.priority || 100) - (b.priority || 100));
|
||||
|
||||
console.log(
|
||||
`[Plugins] Registered "${plugin.name}" (priority: ${plugin.priority}, enabled: ${plugin.enabled})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a plugin by name.
|
||||
*/
|
||||
export function unregisterPlugin(name: string): boolean {
|
||||
const idx = _plugins.findIndex((p) => p.name === name);
|
||||
if (idx === -1) return false;
|
||||
_plugins.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable a plugin at runtime.
|
||||
*/
|
||||
export function setPluginEnabled(name: string, enabled: boolean): boolean {
|
||||
const plugin = _plugins.find((p) => p.name === name);
|
||||
if (!plugin) return false;
|
||||
plugin.enabled = enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered plugins.
|
||||
*/
|
||||
export function listPlugins(): Array<{
|
||||
name: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
hooks: string[];
|
||||
}> {
|
||||
return _plugins.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority || 100,
|
||||
enabled: p.enabled !== false,
|
||||
hooks: [
|
||||
p.onRequest ? "onRequest" : "",
|
||||
p.onResponse ? "onResponse" : "",
|
||||
p.onError ? "onError" : "",
|
||||
].filter(Boolean),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Execution ──
|
||||
|
||||
/**
|
||||
* Run all onRequest hooks. Returns the (possibly modified) context,
|
||||
* or a blocked response if any plugin blocked the request.
|
||||
*/
|
||||
export async function runOnRequest(
|
||||
ctx: PluginContext
|
||||
): Promise<{ blocked: boolean; response?: any; ctx: PluginContext }> {
|
||||
let currentCtx = { ...ctx };
|
||||
|
||||
for (const plugin of _plugins) {
|
||||
if (!plugin.enabled || !plugin.onRequest) continue;
|
||||
|
||||
try {
|
||||
const result = await plugin.onRequest(currentCtx);
|
||||
if (result) {
|
||||
if (result.blocked) {
|
||||
console.log(`[Plugins] Request blocked by "${plugin.name}"`);
|
||||
return { blocked: true, response: result.response, ctx: currentCtx };
|
||||
}
|
||||
if (result.body) currentCtx.body = result.body;
|
||||
if (result.metadata) {
|
||||
currentCtx.metadata = { ...currentCtx.metadata, ...result.metadata };
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Plugins] onRequest error in "${plugin.name}": ${err.message}`);
|
||||
// Plugin errors don't block the pipeline by default
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false, ctx: currentCtx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all onResponse hooks. Returns the (possibly modified) response.
|
||||
*/
|
||||
export async function runOnResponse(ctx: PluginContext, response: any): Promise<any> {
|
||||
let currentResponse = response;
|
||||
|
||||
for (const plugin of _plugins) {
|
||||
if (!plugin.enabled || !plugin.onResponse) continue;
|
||||
|
||||
try {
|
||||
const modified = await plugin.onResponse(ctx, currentResponse);
|
||||
if (modified !== undefined && modified !== null) {
|
||||
currentResponse = modified;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Plugins] onResponse error in "${plugin.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return currentResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all onError hooks. Returns a recovery response if any plugin handles it,
|
||||
* or null to let the error propagate.
|
||||
*/
|
||||
export async function runOnError(ctx: PluginContext, error: Error): Promise<any | null> {
|
||||
for (const plugin of _plugins) {
|
||||
if (!plugin.enabled || !plugin.onError) continue;
|
||||
|
||||
try {
|
||||
const recovery = await plugin.onError(ctx, error);
|
||||
if (recovery !== undefined && recovery !== null) {
|
||||
console.log(`[Plugins] Error recovered by "${plugin.name}"`);
|
||||
return recovery;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Plugins] onError error in "${plugin.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No recovery — let error propagate
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all plugins (for testing).
|
||||
*/
|
||||
export function resetPlugins(): void {
|
||||
_plugins.length = 0;
|
||||
}
|
||||
@@ -160,6 +160,90 @@ export function cleanExpiredEntries() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache entries by model name.
|
||||
* Useful when a model is updated/changed and cached responses are stale.
|
||||
* @param {string} model - Model name to invalidate (exact match)
|
||||
* @returns {number} Number of entries removed
|
||||
*/
|
||||
export function invalidateByModel(model: string): number {
|
||||
getMemoryCache().clear(); // Memory cache doesn't track model; full clear
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare("DELETE FROM semantic_cache WHERE model = ?")
|
||||
.run(model);
|
||||
return result.changes || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a single cache entry by its signature.
|
||||
* @param {string} signature - Cache signature to invalidate
|
||||
* @returns {boolean} Whether the entry was found and removed
|
||||
*/
|
||||
export function invalidateBySignature(signature: string): boolean {
|
||||
getMemoryCache().delete(signature);
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare("DELETE FROM semantic_cache WHERE signature = ?")
|
||||
.run(signature);
|
||||
return (result.changes || 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate entries older than a given age.
|
||||
* @param {number} maxAgeMs - Maximum age in milliseconds
|
||||
* @returns {number} Number of entries removed
|
||||
*/
|
||||
export function invalidateStale(maxAgeMs: number): number {
|
||||
getMemoryCache().clear();
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
||||
const result = db
|
||||
.prepare("DELETE FROM semantic_cache WHERE created_at < ?")
|
||||
.run(cutoff);
|
||||
return result.changes || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-cleanup timer ──
|
||||
|
||||
let _cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start periodic auto-cleanup of expired entries.
|
||||
* @param {number} intervalMs - Cleanup interval (default: 5 minutes)
|
||||
*/
|
||||
export function startAutoCleanup(intervalMs = 300_000): void {
|
||||
stopAutoCleanup();
|
||||
_cleanupTimer = setInterval(() => {
|
||||
const removed = cleanExpiredEntries();
|
||||
if (removed > 0) {
|
||||
console.log(`[SemanticCache] Auto-cleaned ${removed} expired entries`);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic auto-cleanup.
|
||||
*/
|
||||
export function stopAutoCleanup(): void {
|
||||
if (_cleanupTimer) {
|
||||
clearInterval(_cleanupTimer);
|
||||
_cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries.
|
||||
*/
|
||||
|
||||
150
src/lib/toolPolicy.ts
Normal file
150
src/lib/toolPolicy.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tool-Calling Policy — L-4
|
||||
*
|
||||
* Allowlist/denylist for tool (function) calling in LLM requests.
|
||||
* Controls which tool names can be invoked, preventing dangerous
|
||||
* tool use via prompt injection or misconfiguration.
|
||||
*
|
||||
* Configuration via environment variables:
|
||||
* TOOL_POLICY_MODE=allowlist|denylist|disabled (default: disabled)
|
||||
* TOOL_ALLOWLIST=tool1,tool2,tool3
|
||||
* TOOL_DENYLIST=dangerous_tool,exec_command
|
||||
*
|
||||
* @module lib/toolPolicy
|
||||
*/
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ToolPolicyResult {
|
||||
allowed: boolean;
|
||||
denied: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
type PolicyMode = "allowlist" | "denylist" | "disabled";
|
||||
|
||||
// ── Configuration ──
|
||||
|
||||
function getMode(): PolicyMode {
|
||||
return (process.env.TOOL_POLICY_MODE as PolicyMode) || "disabled";
|
||||
}
|
||||
|
||||
function parseList(envKey: string): Set<string> {
|
||||
const raw = process.env[envKey];
|
||||
if (!raw) return new Set();
|
||||
return new Set(
|
||||
raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Runtime overrides (for dashboard/API configuration) ──
|
||||
|
||||
let _runtimeAllowlist: Set<string> | null = null;
|
||||
let _runtimeDenylist: Set<string> | null = null;
|
||||
let _runtimeMode: PolicyMode | null = null;
|
||||
|
||||
/**
|
||||
* Override the policy at runtime (e.g., from dashboard settings).
|
||||
*/
|
||||
export function setRuntimePolicy(config: {
|
||||
mode?: PolicyMode;
|
||||
allowlist?: string[];
|
||||
denylist?: string[];
|
||||
}): void {
|
||||
if (config.mode) _runtimeMode = config.mode;
|
||||
if (config.allowlist) _runtimeAllowlist = new Set(config.allowlist.map((s) => s.toLowerCase()));
|
||||
if (config.denylist) _runtimeDenylist = new Set(config.denylist.map((s) => s.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset runtime overrides.
|
||||
*/
|
||||
export function resetRuntimePolicy(): void {
|
||||
_runtimeMode = null;
|
||||
_runtimeAllowlist = null;
|
||||
_runtimeDenylist = null;
|
||||
}
|
||||
|
||||
// ── Core Logic ──
|
||||
|
||||
/**
|
||||
* Evaluate a list of tool names against the policy.
|
||||
*/
|
||||
export function evaluateToolPolicy(toolNames: string[]): ToolPolicyResult {
|
||||
const mode = _runtimeMode || getMode();
|
||||
|
||||
if (mode === "disabled" || !toolNames || toolNames.length === 0) {
|
||||
return { allowed: true, denied: [] };
|
||||
}
|
||||
|
||||
const normalizedNames = toolNames.map((n) => n.toLowerCase());
|
||||
|
||||
if (mode === "allowlist") {
|
||||
const allowlist = _runtimeAllowlist || parseList("TOOL_ALLOWLIST");
|
||||
if (allowlist.size === 0) {
|
||||
return { allowed: true, denied: [], reason: "Allowlist is empty — all tools permitted" };
|
||||
}
|
||||
|
||||
const denied = normalizedNames.filter((name) => !allowlist.has(name));
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
denied,
|
||||
reason: denied.length > 0 ? `Tools not in allowlist: ${denied.join(", ")}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "denylist") {
|
||||
const denylist = _runtimeDenylist || parseList("TOOL_DENYLIST");
|
||||
const denied = normalizedNames.filter((name) => denylist.has(name));
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
denied,
|
||||
reason: denied.length > 0 ? `Tools in denylist: ${denied.join(", ")}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, denied: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool names from an OpenAI-compatible request body.
|
||||
*/
|
||||
export function extractToolNames(body: any): string[] {
|
||||
const tools: string[] = [];
|
||||
|
||||
// tools array (new format)
|
||||
if (Array.isArray(body?.tools)) {
|
||||
for (const tool of body.tools) {
|
||||
if (tool?.function?.name) {
|
||||
tools.push(tool.function.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// functions array (legacy format)
|
||||
if (Array.isArray(body?.functions)) {
|
||||
for (const fn of body.functions) {
|
||||
if (fn?.name) {
|
||||
tools.push(fn.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tool_choice (if specific tool is forced)
|
||||
if (body?.tool_choice?.function?.name) {
|
||||
tools.push(body.tool_choice.function.name);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: validate an entire request body against the tool policy.
|
||||
*/
|
||||
export function validateToolsInRequest(body: any): ToolPolicyResult {
|
||||
const toolNames = extractToolNames(body);
|
||||
return evaluateToolPolicy(toolNames);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Call Logs — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Cost Calculator — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Usage History — extracted from usageDb.js (T-15)
|
||||
*
|
||||
|
||||
@@ -11,6 +11,9 @@ const LOCAL_PORT = 443;
|
||||
const ROUTER_URL = "http://localhost:20128/v1/chat/completions";
|
||||
const API_KEY = process.env.ROUTER_API_KEY;
|
||||
const DB_FILE = path.join(os.homedir(), ".omniroute", "db.json");
|
||||
const SQLITE_FILE = path.join(os.homedir(), ".omniroute", "storage.sqlite");
|
||||
|
||||
let _sqliteDb = null;
|
||||
|
||||
// Toggle logging (set true to enable file logging for debugging)
|
||||
const ENABLE_FILE_LOG = false;
|
||||
@@ -90,14 +93,56 @@ function extractModel(body) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a lazy SQLite connection for reading MITM aliases.
|
||||
* Falls back to null if better-sqlite3 is unavailable.
|
||||
*/
|
||||
function getSqliteDb() {
|
||||
if (_sqliteDb) return _sqliteDb;
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
if (fs.existsSync(SQLITE_FILE)) {
|
||||
_sqliteDb = new Database(SQLITE_FILE, { readonly: true });
|
||||
return _sqliteDb;
|
||||
}
|
||||
} catch {
|
||||
// better-sqlite3 not available in this process
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMappedModel(model) {
|
||||
if (!model) return null;
|
||||
|
||||
// Primary: read from SQLite key_value table
|
||||
try {
|
||||
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
||||
return db.mitmAlias?.antigravity?.[model] || null;
|
||||
const db = getSqliteDb();
|
||||
if (db) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT value FROM key_value WHERE namespace = 'mitmAlias' AND key = 'antigravity'"
|
||||
)
|
||||
.get();
|
||||
if (row) {
|
||||
const mappings = JSON.parse(row.value);
|
||||
return mappings[model] || null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
// Fall through to JSON fallback
|
||||
}
|
||||
|
||||
// Fallback: read from db.json (legacy installs not yet migrated)
|
||||
try {
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
||||
return db.mitmAlias?.antigravity?.[model] || null;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function passthrough(req, res, bodyBuffer) {
|
||||
|
||||
22
src/proxy.ts
22
src/proxy.ts
@@ -3,6 +3,8 @@ import { jwtVerify } from "jose";
|
||||
import { generateRequestId } from "./shared/utils/requestId";
|
||||
import { getSettings } from "./lib/localDb";
|
||||
import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth";
|
||||
import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard";
|
||||
import { isDraining } from "./lib/gracefulShutdown";
|
||||
|
||||
// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
@@ -19,6 +21,26 @@ export async function proxy(request) {
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("X-Request-Id", requestId);
|
||||
|
||||
// ──────────────── Pre-flight: Reject during shutdown drain ────────────────
|
||||
if (isDraining() && pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Server is shutting down",
|
||||
correlation_id: requestId,
|
||||
},
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── Pre-flight: Reject oversized bodies ────────────────
|
||||
if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") {
|
||||
const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname));
|
||||
if (bodySizeRejection) return bodySizeRejection;
|
||||
}
|
||||
|
||||
// ──────────────── Protect Management API Routes ────────────────
|
||||
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) {
|
||||
// Allow public routes (login, logout, health, etc.)
|
||||
|
||||
83
src/shared/middleware/bodySizeGuard.ts
Normal file
83
src/shared/middleware/bodySizeGuard.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Body Size Guard — E-1 Critical Fix
|
||||
*
|
||||
* Middleware helper that rejects oversized request bodies
|
||||
* before they are parsed, preventing OOM from malicious payloads.
|
||||
*
|
||||
* Usage:
|
||||
* import { checkBodySize, MAX_BODY_BYTES } from "@/shared/middleware/bodySizeGuard";
|
||||
*
|
||||
* const rejection = checkBodySize(request);
|
||||
* if (rejection) return rejection;
|
||||
*
|
||||
* @module shared/middleware/bodySizeGuard
|
||||
*/
|
||||
|
||||
/** Default maximum body size: 10 MB */
|
||||
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Larger limit for backup/import routes: 100 MB */
|
||||
export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024;
|
||||
|
||||
/** Larger limit for audio transcription uploads: 100 MB */
|
||||
export const MAX_BODY_BYTES_AUDIO = 100 * 1024 * 1024;
|
||||
|
||||
/** Configured limit — reads from env or falls back to 10 MB */
|
||||
export const MAX_BODY_BYTES = parseInt(
|
||||
process.env.MAX_BODY_SIZE_BYTES || String(DEFAULT_MAX_BODY_BYTES),
|
||||
10
|
||||
);
|
||||
|
||||
type BodySizeRule = { prefix: string; limit: number };
|
||||
|
||||
const ROUTE_LIMITS: BodySizeRule[] = [
|
||||
{ prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT },
|
||||
{ prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the body size limit for a request path.
|
||||
*/
|
||||
export function getBodySizeLimit(pathname: string): number {
|
||||
const customRule = ROUTE_LIMITS.find((rule) => pathname.startsWith(rule.prefix));
|
||||
return customRule?.limit ?? MAX_BODY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Content-Length header against the configured limit.
|
||||
* Returns a 413 Response if the body is too large, or null if OK.
|
||||
*/
|
||||
export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): Response | null {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
|
||||
if (contentLength) {
|
||||
const bytes = parseInt(contentLength, 10);
|
||||
if (!Number.isNaN(bytes) && bytes > limit) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Request body too large. Maximum allowed: ${formatBytes(limit)}`,
|
||||
type: "payload_too_large",
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 413,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format bytes as human-readable string */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${bytes} bytes`;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Zod Validation Schemas — Shared request schemas for API routes
|
||||
*
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* a11y Audit — Basic WCAG Accessibility Checker
|
||||
*
|
||||
|
||||
@@ -20,6 +20,7 @@ const PUBLIC_API_ROUTES = [
|
||||
// Auth flow — must be accessible to unauthenticated users
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
|
||||
// Settings check — used by login page / onboarding
|
||||
"/api/settings/require-login",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Circuit Breaker — FASE-04 Observability & Resilience
|
||||
*
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* export function OPTIONS() { return handleCorsOptions(); }
|
||||
*/
|
||||
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
|
||||
export const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
|
||||
|
||||
/**
|
||||
* Standard CORS headers to spread into any Response.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Cost Estimator — Pre-flight cost estimation for LLM requests
|
||||
*
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* @module shared/utils/fetchTimeout
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes
|
||||
const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Input Sanitizer — FASE-01 Security Hardening
|
||||
*
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* @module shared/utils/requestId
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Stream Tracker — Unified SSE stream monitoring
|
||||
*
|
||||
|
||||
@@ -212,9 +212,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
/**
|
||||
* Handle single model chat request
|
||||
*
|
||||
* Refactored (T-28): model resolution, logging, and param building
|
||||
* extracted to chatHelpers.js. This function now focuses on the
|
||||
* credential retry loop.
|
||||
* Refactored: model resolution, logging, pipeline gates, and chat execution
|
||||
* extracted to focused helpers. This function orchestrates the credential
|
||||
* retry loop.
|
||||
*/
|
||||
async function handleSingleModelChat(
|
||||
body: any,
|
||||
@@ -225,62 +225,26 @@ async function handleSingleModelChat(
|
||||
apiKeyInfo: any = null,
|
||||
telemetry: any = null
|
||||
) {
|
||||
// 1. Resolve model → provider/model (or return error)
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
if (!modelInfo.provider) {
|
||||
if ((modelInfo as any).errorType === "ambiguous_model") {
|
||||
const message =
|
||||
(modelInfo as any).errorMessage ||
|
||||
`Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`;
|
||||
log.warn("CHAT", message, {
|
||||
model: modelStr,
|
||||
candidates: (modelInfo as any).candidateAliases || (modelInfo as any).candidateProviders || [],
|
||||
});
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
}
|
||||
// 1. Resolve model → provider/model
|
||||
const resolved = await resolveModelOrError(modelStr, body);
|
||||
if (resolved.error) return resolved.error;
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
const sourceFormat = detectFormat(body);
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
|
||||
const { provider, model, sourceFormat, targetFormat } = resolved;
|
||||
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
}
|
||||
// 2. Pipeline gates (availability + circuit breaker)
|
||||
const gate = checkPipelineGates(provider, model);
|
||||
if (gate) return gate;
|
||||
|
||||
// Pipeline: Check model availability (TTL cooldown)
|
||||
if (!isModelAvailable(provider, model)) {
|
||||
log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`);
|
||||
return (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Model ${provider}/${model} is temporarily unavailable (cooldown)`,
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
// Pipeline: Check circuit breaker for this provider
|
||||
const breaker = getCircuitBreaker(provider, {
|
||||
failureThreshold: 5,
|
||||
resetTimeout: 30000,
|
||||
onStateChange: (name: string, from: string, to: string) => log.info("CIRCUIT", `${name}: ${from} → ${to}`),
|
||||
onStateChange: (name: string, from: string, to: string) =>
|
||||
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
|
||||
});
|
||||
if (!breaker.canExecute()) {
|
||||
log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`);
|
||||
return (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Provider ${provider} circuit breaker is open`,
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = request?.headers?.get("user-agent") || "";
|
||||
|
||||
// 2. Credential retry loop
|
||||
// 3. Credential retry loop
|
||||
let excludeConnectionId = null;
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
@@ -288,7 +252,6 @@ async function handleSingleModelChat(
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionId);
|
||||
|
||||
// All accounts unavailable — return error
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
return handleNoCredentials(
|
||||
credentials,
|
||||
@@ -307,63 +270,27 @@ async function handleSingleModelChat(
|
||||
const proxyInfo = await safeResolveProxy(credentials.connectionId);
|
||||
const proxyStartTime = Date.now();
|
||||
|
||||
// 3. Execute chat via core (with circuit breaker)
|
||||
// 4. Execute chat via core (with circuit breaker + optional TLS)
|
||||
if (telemetry) telemetry.startPhase("connect");
|
||||
let result;
|
||||
let tlsFingerprintUsed = false;
|
||||
try {
|
||||
const chatFn = () =>
|
||||
runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
(handleChatCore as any)({
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
credentials: refreshedCredentials,
|
||||
log,
|
||||
clientRawRequest,
|
||||
connectionId: credentials.connectionId,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
onCredentialsRefreshed: async (newCreds: any) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Wrap with TLS tracking when no proxy and TLS fingerprint is active
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await breaker.execute(async () => {
|
||||
return await runWithTlsTracking(chatFn);
|
||||
});
|
||||
result = tracked.result;
|
||||
tlsFingerprintUsed = tracked.tlsFingerprintUsed;
|
||||
} else {
|
||||
result = await breaker.execute(chatFn);
|
||||
}
|
||||
} catch (cbErr) {
|
||||
if (cbErr instanceof CircuitBreakerOpenError) {
|
||||
log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`);
|
||||
return (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Provider ${provider} circuit breaker is open`,
|
||||
Math.ceil(cbErr.retryAfterMs / 1000)
|
||||
);
|
||||
}
|
||||
throw cbErr;
|
||||
}
|
||||
const { result, tlsFingerprintUsed } = await executeChatWithBreaker({
|
||||
breaker,
|
||||
body,
|
||||
provider,
|
||||
model,
|
||||
refreshedCredentials,
|
||||
proxyInfo,
|
||||
log,
|
||||
clientRawRequest,
|
||||
credentials,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
});
|
||||
if (telemetry) telemetry.endPhase();
|
||||
|
||||
const proxyLatency = Date.now() - proxyStartTime;
|
||||
|
||||
// 4. Log proxy + translation events (fire-and-forget)
|
||||
// 5. Log proxy + translation events
|
||||
safeLogEvents({
|
||||
result,
|
||||
proxyInfo,
|
||||
@@ -379,21 +306,13 @@ async function handleSingleModelChat(
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Pipeline: Record cost on success
|
||||
if (apiKeyInfo?.id) {
|
||||
try {
|
||||
const usage = result.usage || {};
|
||||
const estimatedCost =
|
||||
((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; // rough estimate
|
||||
if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost);
|
||||
} catch {}
|
||||
}
|
||||
recordCostIfNeeded(apiKeyInfo, result);
|
||||
if (telemetry) telemetry.startPhase("finalize");
|
||||
if (telemetry) telemetry.endPhase();
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// Pipeline: Mark model unavailable on repeated failures (429, 503)
|
||||
// Pipeline: Mark model unavailable on repeated failures
|
||||
if (result.status === 429 || result.status === 503) {
|
||||
setModelUnavailable(provider, model, 60000, `HTTP ${result.status}`);
|
||||
log.info(
|
||||
@@ -402,7 +321,7 @@ async function handleSingleModelChat(
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Fallback to next account
|
||||
// 6. Fallback to next account
|
||||
const { shouldFallback } = await markAccountUnavailable(
|
||||
credentials.connectionId,
|
||||
result.status,
|
||||
@@ -422,6 +341,162 @@ async function handleSingleModelChat(
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Pipeline gate checks ────
|
||||
|
||||
/**
|
||||
* Resolve model string to provider/model info, or return an error response.
|
||||
*/
|
||||
async function resolveModelOrError(modelStr: string, body: any) {
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
if (!modelInfo.provider) {
|
||||
if ((modelInfo as any).errorType === "ambiguous_model") {
|
||||
const message =
|
||||
(modelInfo as any).errorMessage ||
|
||||
`Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`;
|
||||
log.warn("CHAT", message, {
|
||||
model: modelStr,
|
||||
candidates:
|
||||
(modelInfo as any).candidateAliases || (modelInfo as any).candidateProviders || [],
|
||||
});
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) };
|
||||
}
|
||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
|
||||
}
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
const sourceFormat = detectFormat(body);
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
|
||||
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
}
|
||||
|
||||
return { provider, model, sourceFormat, targetFormat };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pipeline gates: model availability + circuit breaker state.
|
||||
* Returns an error Response if blocked, or null if OK to proceed.
|
||||
*/
|
||||
function checkPipelineGates(provider: string, model: string) {
|
||||
if (!isModelAvailable(provider, model)) {
|
||||
log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`);
|
||||
return (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Model ${provider}/${model} is temporarily unavailable (cooldown)`,
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
const breaker = getCircuitBreaker(provider, {
|
||||
failureThreshold: 5,
|
||||
resetTimeout: 30000,
|
||||
onStateChange: (name: string, from: string, to: string) =>
|
||||
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
|
||||
});
|
||||
if (!breaker.canExecute()) {
|
||||
log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`);
|
||||
return (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Provider ${provider} circuit breaker is open`,
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──── Chat execution with circuit breaker ────
|
||||
|
||||
/**
|
||||
* Execute chat core wrapped in circuit breaker + optional TLS tracking.
|
||||
*/
|
||||
async function executeChatWithBreaker({
|
||||
breaker,
|
||||
body,
|
||||
provider,
|
||||
model,
|
||||
refreshedCredentials,
|
||||
proxyInfo,
|
||||
log: logger,
|
||||
clientRawRequest,
|
||||
credentials,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
|
||||
let tlsFingerprintUsed = false;
|
||||
|
||||
try {
|
||||
const chatFn = () =>
|
||||
runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
(handleChatCore as any)({
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
credentials: refreshedCredentials,
|
||||
log: logger,
|
||||
clientRawRequest,
|
||||
connectionId: credentials.connectionId,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
onCredentialsRefreshed: async (newCreds: any) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn));
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
const result = await breaker.execute(chatFn);
|
||||
return { result, tlsFingerprintUsed: false };
|
||||
} catch (cbErr) {
|
||||
if (cbErr instanceof CircuitBreakerOpenError) {
|
||||
log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`);
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
response: (unavailableResponse as any)(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
`Provider ${provider} circuit breaker is open`,
|
||||
Math.ceil(cbErr.retryAfterMs / 1000)
|
||||
),
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
},
|
||||
tlsFingerprintUsed: false,
|
||||
};
|
||||
}
|
||||
throw cbErr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cost if API key has budget tracking enabled.
|
||||
*/
|
||||
function recordCostIfNeeded(apiKeyInfo: any, result: any) {
|
||||
if (!apiKeyInfo?.id) return;
|
||||
try {
|
||||
const usage = result.usage || {};
|
||||
const estimatedCost = ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001;
|
||||
if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ──── Extracted helpers (T-28) ────
|
||||
|
||||
function handleNoCredentials(
|
||||
|
||||
399
tests/integration/proxy-pipeline.test.mjs
Normal file
399
tests/integration/proxy-pipeline.test.mjs
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Proxy Pipeline Integration Tests — T-3
|
||||
*
|
||||
* Tests the proxy pipeline wiring: format detection, credential retry loop,
|
||||
* circuit breaker integration, and the new Phase 2 modules (DI container,
|
||||
* prompt versioning, plugin architecture, eval scheduler).
|
||||
*
|
||||
* @module tests/integration/proxy-pipeline.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
|
||||
function readSrc(relPath) {
|
||||
const full = join(ROOT, "src", relPath);
|
||||
if (!existsSync(full)) return null;
|
||||
return readFileSync(full, "utf8");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 1. Chat Handler Pipeline Wiring
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("Chat Pipeline — handleSingleModelChat decomposition", () => {
|
||||
const src = readSrc("sse/handlers/chat.ts");
|
||||
|
||||
it("should define resolveModelOrError helper", () => {
|
||||
assert.ok(src, "chat.ts should exist");
|
||||
assert.match(src, /function\s+resolveModelOrError/);
|
||||
});
|
||||
|
||||
it("should define checkPipelineGates helper", () => {
|
||||
assert.match(src, /function\s+checkPipelineGates/);
|
||||
});
|
||||
|
||||
it("should define executeChatWithBreaker helper", () => {
|
||||
assert.match(src, /function\s+executeChatWithBreaker/);
|
||||
});
|
||||
|
||||
it("should define recordCostIfNeeded helper", () => {
|
||||
assert.match(src, /function\s+recordCostIfNeeded/);
|
||||
});
|
||||
|
||||
it("handleSingleModelChat should use resolveModelOrError", () => {
|
||||
// Extract handleSingleModelChat body
|
||||
assert.match(src, /resolveModelOrError\(modelStr/);
|
||||
});
|
||||
|
||||
it("handleSingleModelChat should use checkPipelineGates", () => {
|
||||
assert.match(src, /checkPipelineGates\(provider/);
|
||||
});
|
||||
|
||||
it("handleSingleModelChat should use executeChatWithBreaker", () => {
|
||||
assert.match(src, /executeChatWithBreaker\(/);
|
||||
});
|
||||
|
||||
it("handleSingleModelChat should use recordCostIfNeeded", () => {
|
||||
assert.match(src, /recordCostIfNeeded\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat Pipeline — combo fallback support", () => {
|
||||
const src = readSrc("sse/handlers/chat.ts");
|
||||
|
||||
it("should import handleComboChat", () => {
|
||||
assert.ok(src, "chat.ts should exist");
|
||||
assert.match(src, /handleComboChat/);
|
||||
});
|
||||
|
||||
it("should delegate to handleSingleModelChat for each combo model", () => {
|
||||
assert.match(src, /handleSingleModel.*handleSingleModelChat/s);
|
||||
});
|
||||
|
||||
it("should check model availability before attempting combo models", () => {
|
||||
assert.match(src, /isModelAvailable/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat Pipeline — circuit breaker integration", () => {
|
||||
const src = readSrc("sse/handlers/chat.ts");
|
||||
|
||||
it("should import CircuitBreakerOpenError", () => {
|
||||
assert.ok(src, "chat.ts should exist");
|
||||
assert.match(src, /CircuitBreakerOpenError/);
|
||||
});
|
||||
|
||||
it("should handle CircuitBreakerOpenError with retry-after", () => {
|
||||
assert.match(src, /retryAfterMs/);
|
||||
});
|
||||
|
||||
it("should reject requests when circuit is open", () => {
|
||||
assert.match(src, /circuit breaker is open/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 2. DI Container (A-5)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("DI Container — container.ts", () => {
|
||||
let container;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../../src/lib/container.ts");
|
||||
container = mod.container;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Don't reset — keep default registrations
|
||||
});
|
||||
|
||||
it("should export a container singleton", () => {
|
||||
assert.ok(container);
|
||||
assert.equal(typeof container.register, "function");
|
||||
assert.equal(typeof container.resolve, "function");
|
||||
assert.equal(typeof container.has, "function");
|
||||
});
|
||||
|
||||
it("should register and resolve a custom service", () => {
|
||||
container.register("testService", () => ({ greeting: "hello" }));
|
||||
const svc = container.resolve("testService");
|
||||
assert.deepEqual(svc, { greeting: "hello" });
|
||||
});
|
||||
|
||||
it("should return cached singleton on repeated resolve", () => {
|
||||
let count = 0;
|
||||
container.register("counterService", () => ({ value: ++count }));
|
||||
const a = container.resolve("counterService");
|
||||
const b = container.resolve("counterService");
|
||||
assert.strictEqual(a, b);
|
||||
assert.equal(a.value, 1);
|
||||
});
|
||||
|
||||
it("should throw on resolving unregistered service", () => {
|
||||
assert.throws(() => container.resolve("nonExistent"), /No factory registered/);
|
||||
});
|
||||
|
||||
it("should have default registrations", () => {
|
||||
const names = container.list();
|
||||
assert.ok(names.includes("settings"), "should have settings");
|
||||
assert.ok(names.includes("db"), "should have db");
|
||||
assert.ok(names.includes("encryption"), "should have encryption");
|
||||
assert.ok(names.includes("policyEngine"), "should have policyEngine");
|
||||
assert.ok(names.includes("circuitBreaker"), "should have circuitBreaker");
|
||||
assert.ok(names.includes("telemetry"), "should have telemetry");
|
||||
});
|
||||
|
||||
it("should support re-registration (overwrite)", () => {
|
||||
container.register("testOverwrite", () => "v1");
|
||||
assert.equal(container.resolve("testOverwrite"), "v1");
|
||||
container.register("testOverwrite", () => "v2");
|
||||
assert.equal(container.resolve("testOverwrite"), "v2");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 3. Plugin Architecture (L-8)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("Plugin Architecture — plugins/index.ts", () => {
|
||||
let plugins;
|
||||
|
||||
beforeEach(async () => {
|
||||
plugins = await import("../../src/lib/plugins/index.ts");
|
||||
plugins.resetPlugins();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
plugins.resetPlugins();
|
||||
});
|
||||
|
||||
it("should register and list plugins", () => {
|
||||
plugins.registerPlugin({
|
||||
name: "test-logger",
|
||||
priority: 10,
|
||||
onRequest: () => {},
|
||||
});
|
||||
|
||||
const list = plugins.listPlugins();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].name, "test-logger");
|
||||
assert.equal(list[0].priority, 10);
|
||||
assert.deepEqual(list[0].hooks, ["onRequest"]);
|
||||
});
|
||||
|
||||
it("should sort plugins by priority", () => {
|
||||
plugins.registerPlugin({ name: "low", priority: 200 });
|
||||
plugins.registerPlugin({ name: "high", priority: 1 });
|
||||
plugins.registerPlugin({ name: "mid", priority: 50 });
|
||||
|
||||
const list = plugins.listPlugins();
|
||||
assert.deepEqual(
|
||||
list.map((p) => p.name),
|
||||
["high", "mid", "low"]
|
||||
);
|
||||
});
|
||||
|
||||
it("should run onRequest hooks in order", async () => {
|
||||
const order = [];
|
||||
plugins.registerPlugin({
|
||||
name: "first",
|
||||
priority: 1,
|
||||
onRequest: () => {
|
||||
order.push("first");
|
||||
},
|
||||
});
|
||||
plugins.registerPlugin({
|
||||
name: "second",
|
||||
priority: 2,
|
||||
onRequest: () => {
|
||||
order.push("second");
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = { requestId: "r1", body: {}, model: "test", metadata: {} };
|
||||
await plugins.runOnRequest(ctx);
|
||||
assert.deepEqual(order, ["first", "second"]);
|
||||
});
|
||||
|
||||
it("should support request blocking", async () => {
|
||||
plugins.registerPlugin({
|
||||
name: "blocker",
|
||||
priority: 1,
|
||||
onRequest: () => ({ blocked: true, response: { error: "denied" } }),
|
||||
});
|
||||
plugins.registerPlugin({
|
||||
name: "never-runs",
|
||||
priority: 2,
|
||||
onRequest: () => {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = { requestId: "r2", body: {}, model: "test", metadata: {} };
|
||||
const result = await plugins.runOnRequest(ctx);
|
||||
assert.equal(result.blocked, true);
|
||||
assert.deepEqual(result.response, { error: "denied" });
|
||||
});
|
||||
|
||||
it("should enable/disable plugins at runtime", () => {
|
||||
plugins.registerPlugin({
|
||||
name: "toggle-me",
|
||||
onRequest: () => {},
|
||||
});
|
||||
|
||||
assert.ok(plugins.setPluginEnabled("toggle-me", false));
|
||||
const list = plugins.listPlugins();
|
||||
assert.equal(list[0].enabled, false);
|
||||
});
|
||||
|
||||
it("should unregister plugins", () => {
|
||||
plugins.registerPlugin({ name: "removable" });
|
||||
assert.equal(plugins.listPlugins().length, 1);
|
||||
assert.ok(plugins.unregisterPlugin("removable"));
|
||||
assert.equal(plugins.listPlugins().length, 0);
|
||||
});
|
||||
|
||||
it("should run onResponse hooks", async () => {
|
||||
plugins.registerPlugin({
|
||||
name: "response-modifier",
|
||||
onResponse: (_ctx, response) => ({ ...response, modified: true }),
|
||||
});
|
||||
|
||||
const ctx = { requestId: "r3", body: {}, model: "test", metadata: {} };
|
||||
const result = await plugins.runOnResponse(ctx, { data: "original" });
|
||||
assert.equal(result.modified, true);
|
||||
assert.equal(result.data, "original");
|
||||
});
|
||||
|
||||
it("should run onError hooks and allow recovery", async () => {
|
||||
plugins.registerPlugin({
|
||||
name: "error-handler",
|
||||
onError: (_ctx, _error) => ({ recovered: true }),
|
||||
});
|
||||
|
||||
const ctx = { requestId: "r4", body: {}, model: "test", metadata: {} };
|
||||
const result = await plugins.runOnError(ctx, new Error("test error"));
|
||||
assert.deepEqual(result, { recovered: true });
|
||||
});
|
||||
|
||||
it("should return null from onError if no recovery", async () => {
|
||||
const ctx = { requestId: "r5", body: {}, model: "test", metadata: {} };
|
||||
const result = await plugins.runOnError(ctx, new Error("unhandled"));
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 4. Prompt Template Versioning (L-6)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("Prompt Template Versioning — prompts.ts module existence", () => {
|
||||
it("prompts.ts should exist", () => {
|
||||
const full = join(ROOT, "src", "lib", "db", "prompts.ts");
|
||||
assert.ok(existsSync(full), "prompts.ts should exist");
|
||||
});
|
||||
|
||||
it("should export CRUD functions", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8");
|
||||
assert.match(src, /export function savePrompt/);
|
||||
assert.match(src, /export function getActivePrompt/);
|
||||
assert.match(src, /export function getPromptVersion/);
|
||||
assert.match(src, /export function listPromptVersions/);
|
||||
assert.match(src, /export function listPrompts/);
|
||||
assert.match(src, /export function rollbackPrompt/);
|
||||
assert.match(src, /export function renderPrompt/);
|
||||
});
|
||||
|
||||
it("should define PromptTemplate interface", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8");
|
||||
assert.match(src, /export interface PromptTemplate/);
|
||||
});
|
||||
|
||||
it("should use content hashing for deduplication", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8");
|
||||
assert.match(src, /content_hash/);
|
||||
assert.match(src, /sha256/);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 5. Eval Scheduler (L-7)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("Eval Scheduler — scheduler.ts module existence", () => {
|
||||
it("scheduler.ts should exist", () => {
|
||||
const full = join(ROOT, "src", "lib", "evals", "scheduler.ts");
|
||||
assert.ok(existsSync(full), "scheduler.ts should exist");
|
||||
});
|
||||
|
||||
it("should export scheduling functions", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8");
|
||||
assert.match(src, /export function schedule/);
|
||||
assert.match(src, /export function unschedule/);
|
||||
assert.match(src, /export function pause/);
|
||||
assert.match(src, /export function resume/);
|
||||
assert.match(src, /export\s+(async\s+)?function\s+runNow/);
|
||||
assert.match(src, /export function getSchedules/);
|
||||
assert.match(src, /export function getHistory/);
|
||||
assert.match(src, /export function stopAll/);
|
||||
});
|
||||
|
||||
it("should define ScheduledEval and EvalRunResult types", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8");
|
||||
assert.match(src, /export interface ScheduledEval/);
|
||||
assert.match(src, /export interface EvalRunResult/);
|
||||
});
|
||||
|
||||
it("should have pluggable output provider", () => {
|
||||
const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8");
|
||||
assert.match(src, /export function setOutputProvider/);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 6. Migration Runner (E-5)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("Migration System — files exist", () => {
|
||||
it("migrationRunner.ts should exist", () => {
|
||||
const full = join(ROOT, "src", "lib", "db", "migrationRunner.ts");
|
||||
assert.ok(existsSync(full), "migrationRunner.ts should exist");
|
||||
});
|
||||
|
||||
it("001_initial_schema.sql should exist", () => {
|
||||
const full = join(ROOT, "src", "lib", "db", "migrations", "001_initial_schema.sql");
|
||||
assert.ok(existsSync(full), "001_initial_schema.sql should exist");
|
||||
});
|
||||
|
||||
it("core.ts should reference migration runner", () => {
|
||||
const src = readSrc("lib/db/core.ts");
|
||||
assert.ok(src);
|
||||
assert.match(src, /runMigrations/);
|
||||
assert.match(src, /_omniroute_migrations/);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 7. CORS Configuration (L-5)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
describe("CORS — centralized configuration", () => {
|
||||
it("shared/utils/cors.ts should exist", () => {
|
||||
const full = join(ROOT, "src", "shared", "utils", "cors.ts");
|
||||
assert.ok(existsSync(full), "shared/utils/cors.ts should exist");
|
||||
});
|
||||
|
||||
it("should export CORS_HEADERS and CORS_ORIGIN", () => {
|
||||
const src = readSrc("shared/utils/cors.ts");
|
||||
assert.match(src, /CORS_HEADERS/);
|
||||
assert.match(src, /CORS_ORIGIN/);
|
||||
});
|
||||
});
|
||||
151
tests/load/proxy-load.js
Normal file
151
tests/load/proxy-load.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* OmniRoute — k6 Load / Performance Test (T-5)
|
||||
*
|
||||
* Tests the proxy endpoint under sustained load to measure:
|
||||
* - Request throughput (RPS)
|
||||
* - Response latency (p50, p95, p99)
|
||||
* - Error rate
|
||||
* - Concurrent connection handling
|
||||
*
|
||||
* Usage:
|
||||
* k6 run tests/load/proxy-load.js
|
||||
* k6 run tests/load/proxy-load.js --env BASE_URL=https://llms.omniroute.online
|
||||
* k6 run tests/load/proxy-load.js --env VUS=50 --env DURATION=120s
|
||||
*
|
||||
* Prerequisites:
|
||||
* - k6 installed: https://grafana.com/docs/k6/latest/set-up/install-k6/
|
||||
* - OMNIROUTE_API_KEY env var or --env API_KEY=... set
|
||||
*/
|
||||
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
import { Rate, Trend } from "k6/metrics";
|
||||
|
||||
// ── Custom metrics ──
|
||||
const errorRate = new Rate("errors");
|
||||
const chatLatency = new Trend("chat_latency", true); // in ms
|
||||
const healthLatency = new Trend("health_latency", true);
|
||||
|
||||
// ── Configuration ──
|
||||
const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
|
||||
const API_KEY = __ENV.API_KEY || __ENV.OMNIROUTE_API_KEY || "test-key";
|
||||
const VUS = parseInt(__ENV.VUS || "10", 10);
|
||||
const DURATION = __ENV.DURATION || "60s";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
// Ramp-up scenario for stress testing
|
||||
chat_stress: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 1,
|
||||
stages: [
|
||||
{ duration: "10s", target: VUS }, // Ramp up
|
||||
{ duration: DURATION, target: VUS }, // Sustained load
|
||||
{ duration: "10s", target: 0 }, // Ramp down
|
||||
],
|
||||
exec: "chatCompletions",
|
||||
},
|
||||
// Constant rate for health checks
|
||||
health_check: {
|
||||
executor: "constant-vus",
|
||||
vus: 2,
|
||||
duration: DURATION,
|
||||
exec: "healthCheck",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<5000"], // 95% of requests < 5s
|
||||
errors: ["rate<0.1"], // Error rate < 10%
|
||||
chat_latency: ["p(50)<3000", "p(95)<8000"],
|
||||
health_latency: ["p(95)<500"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Headers ──
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
};
|
||||
|
||||
// ── Scenarios ──
|
||||
|
||||
/**
|
||||
* Chat Completions — main proxy endpoint
|
||||
* Sends a simple non-streaming chat request.
|
||||
*/
|
||||
export function chatCompletions() {
|
||||
const payload = JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "user", content: "Say hello in one word." },
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 10,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const res = http.post(`${BASE_URL}/v1/chat/completions`, payload, {
|
||||
headers,
|
||||
timeout: "15s",
|
||||
});
|
||||
|
||||
chatLatency.add(res.timings.duration);
|
||||
|
||||
const passed = check(res, {
|
||||
"status is 200": (r) => r.status === 200,
|
||||
"has choices": (r) => {
|
||||
try {
|
||||
const body = JSON.parse(r.body);
|
||||
return body.choices && body.choices.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"response time < 10s": (r) => r.timings.duration < 10000,
|
||||
});
|
||||
|
||||
errorRate.add(!passed);
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health Check — lightweight endpoint to measure base latency.
|
||||
*/
|
||||
export function healthCheck() {
|
||||
const res = http.get(`${BASE_URL}/api/health`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
timeout: "5s",
|
||||
});
|
||||
|
||||
healthLatency.add(res.timings.duration);
|
||||
|
||||
const passed = check(res, {
|
||||
"health status 200": (r) => r.status === 200,
|
||||
"response time < 1s": (r) => r.timings.duration < 1000,
|
||||
});
|
||||
|
||||
errorRate.add(!passed);
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary handler — outputs a custom summary.
|
||||
*/
|
||||
export function handleSummary(data) {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
scenarios: Object.keys(options.scenarios),
|
||||
metrics: {
|
||||
http_reqs: data.metrics.http_reqs?.values?.count || 0,
|
||||
avg_duration_ms: Math.round(data.metrics.http_req_duration?.values?.avg || 0),
|
||||
p95_duration_ms: Math.round(data.metrics.http_req_duration?.values?.["p(95)"] || 0),
|
||||
p99_duration_ms: Math.round(data.metrics.http_req_duration?.values?.["p(99)"] || 0),
|
||||
error_rate: (data.metrics.errors?.values?.rate || 0).toFixed(4),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stdout: `\n📊 Load Test Summary\n${JSON.stringify(summary, null, 2)}\n`,
|
||||
"tests/load/results.json": JSON.stringify(summary, null, 2),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": true,
|
||||
@@ -14,21 +10,16 @@
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@omniroute/open-sse": [
|
||||
"./open-sse"
|
||||
],
|
||||
"@omniroute/open-sse/*": [
|
||||
"./open-sse/*"
|
||||
]
|
||||
"@/*": ["./src/*"],
|
||||
"@omniroute/open-sse": ["./open-sse"],
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -45,9 +36,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"open-sse",
|
||||
"antigravity-manager-analysis"
|
||||
]
|
||||
"exclude": ["node_modules", "open-sse", "antigravity-manager-analysis"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user