Merge pull request #79 from diegosouzapw/features-improvements

v1.0.2 — Security Hardening, Architecture & UX Polish
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-02-18 17:09:56 -03:00
committed by GitHub
96 changed files with 3673 additions and 496 deletions

View File

@@ -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

View File

@@ -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 12)
- **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 34)
- **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

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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 |
---

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",
},
});

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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(),
},
}),
};

View File

@@ -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
View 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;
}

View File

@@ -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,

View File

@@ -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
View File

@@ -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",

View File

@@ -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}": [

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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 (

View 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 });
}
}

View File

@@ -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 });
}

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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" },
});
}

View File

@@ -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

View File

@@ -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": "*",
},

View File

@@ -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,
},
}
);

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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" },
});
}

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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

View File

@@ -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": "*",
},

View File

@@ -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": "*",
},

View File

@@ -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
View 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>
);
}

View File

@@ -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>
);

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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&apos;re looking for doesn&apos;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>

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Combo Resolver — FASE-09 Domain Extraction (T-46)
*

View File

@@ -9,7 +9,6 @@
* @module domain/costRules
*/
// @ts-check
import {
saveBudget,

View File

@@ -10,7 +10,6 @@
* @module domain/fallbackPolicy
*/
// @ts-check
import {
saveFallbackChain,

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Lockout Policy — FASE-09 Domain Extraction (T-46)
*

View File

@@ -9,7 +9,6 @@
* @module domain/modelAvailability
*/
// @ts-check
/**
* @typedef {Object} UnavailableEntry

16
src/instrumentation.ts Normal file
View 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();
}
}

View File

@@ -9,7 +9,6 @@
* @module lib/compliance
*/
// @ts-check
import { getDbInstance } from "../db/core";

122
src/lib/container.ts Normal file
View 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;

View File

@@ -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);

View 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;

View 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 };
}

View 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
View 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,
};
}

View File

@@ -8,7 +8,6 @@
* @module lib/evals/evalRunner
*/
// @ts-check
/**
* @typedef {Object} EvalCase

275
src/lib/evals/scheduler.ts Normal file
View 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
View 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
View 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
View 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;
}

View File

@@ -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
View 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);
}

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Call Logs — extracted from usageDb.js (T-15)
*

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Cost Calculator — extracted from usageDb.js (T-15)
*

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Usage History — extracted from usageDb.js (T-15)
*

View File

@@ -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) {

View File

@@ -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.)

View 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`;
}

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Zod Validation Schemas — Shared request schemas for API routes
*

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* a11y Audit — Basic WCAG Accessibility Checker
*

View File

@@ -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",

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Circuit Breaker — FASE-04 Observability & Resilience
*

View File

@@ -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.

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Cost Estimator — Pre-flight cost estimation for LLM requests
*

View File

@@ -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;

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Input Sanitizer — FASE-01 Security Hardening
*

View File

@@ -10,7 +10,6 @@
* @module shared/utils/requestId
*/
// @ts-check
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";

View File

@@ -1,4 +1,3 @@
// @ts-check
/**
* Stream Tracker — Unified SSE stream monitoring
*

View File

@@ -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(

View 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
View 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),
};
}

View File

@@ -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"]
}