Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm verify
- run: pnpm audit --prod --audit-level=high

e2e:
runs-on: ubuntu-latest
Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Changelog

All notable changes to Sentinel are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] — 2026-06-27

First public release: a self-hostable, OpenAI-compatible **verifying LLM gateway** that
routes, semantically caches, and verifies every call, with full OpenTelemetry tracing and a
dashboard. Adopting it is a one-line base-URL change.

### Proxy & providers

- Drop-in OpenAI-compatible `POST /v1/chat/completions`, streaming and non-streaming.
- Bearer auth for callers; provider keys held server-side and never exposed to clients.
- Unified `openai-compatible` provider — OpenAI, Groq, Mistral, OpenRouter, DeepSeek, xAI, Google Gemini (OpenAI endpoint), and local Ollama — selected by config, not code.

### Routing & resilience

- Ordered candidate chain with retry + exponential backoff and fail-over to configured fallback models, ending at a local model so a request can always be served.
- Per-provider token-bucket throttling to stay under each provider's `rpm`; terminal errors (400/401/403/404) fail fast without pointless fallback.
- Opt-in cost-aware routing: `"model": "auto"` picks the cheapest capable tier by prompt complexity, escalating on failure.

### Semantic cache

- Local prompt embeddings (Ollama `nomic-embed-text`) with cosine-similarity lookup; hits are served without a provider call, replaying buffered SSE for streamed requests.
- Per-tenant isolation (scoped to the calling key), bounded by TTL + max-entries, and fail-open on any embedding error.

### Verification

- Inline deterministic guardrails (JSON validity + schema, and a PII/policy engine: emails, Luhn-checked cards, SSNs, phones, IPs, API-key-like tokens, content blocklist, refusal detection). Flagged by default; `GUARDRAILS_BLOCK=true` returns 422; always fails closed.
- Async local LLM-as-judge scoring sampled responses out of band (no added latency), with prompt-injection-resistant wrapping and "unscored" on failure.
- Regression tracking: a model-independent prompt fingerprint groups judge scores by `(prompt, model)` via `GET /regression`.

### Observability

- OpenTelemetry trace per request (provider, model, status, latency, tokens, cache hit, fallback, verdict), persisted to SQLite (or in-memory) and queryable via an admin-gated `GET /traces` with filters; optional OTLP export.
- Traces are metadata-only — no prompt or response bodies; API keys recorded as SHA-256 hashes.
- Read-only React + Vite dashboard aggregating the trace API client-side.

### Security & operations

- Per-client rate limiting (`CLIENT_RPM`) keyed by hashed API key — a single noisy key gets 429s without affecting other clients.
- Upstream fetches refuse redirects (SSRF hardening); authorization headers redacted from logs.
- CI runs typecheck + lint + the full coverage gate plus a production dependency audit. Security posture tracked in `SECURITY_REVIEW_LOG.md`.
- Reproducible load harness (`pnpm load`) producing the headline benchmarks in `load/RESULTS.md`: 50% cache cost-reduction on a 50%-repeat workload, zero unhandled 429s, 100% PII guardrail catch-rate, and ~14 ms p99 added overhead.

[0.1.0]: https://github.com/MANVENDRA-github/sentinel/releases/tag/v0.1.0
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Manvendra

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
8 changes: 4 additions & 4 deletions PRP_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ The wedge: existing gateways and observability tools (LiteLLM, Helicone, Langfus
## 5. Success criteria (the metrics this project must produce)

- **Drop-in:** an existing OpenAI-SDK app works against Sentinel with only a base-URL change.
- **Cost:** demonstrated **≥ X% spend reduction** on a mixed workload via routing + cache (headline number for the case study).
- **Reliability:** **zero unhandled 429s** to the client under a load test that exceeds a single free-tier limit (served via throttle + cache + fallback).
- **Quality gate:** measured catch-rate of injected bad outputs (malformed/off-topic), at **< Y ms p99 added overhead** for the inline path.
- **Cost:** demonstrated **50% spend reduction** on a workload with 50% repeated prompts via the semantic cache — 100 of 200 requests served from memory with zero upstream calls (headline number for the case study; routing adds further savings on mixed-capability workloads).
- **Reliability:** **zero unhandled 429s** to the client — measured 0 of 50 requests to an always-429 upstream reached the client; every one was retried and **failed over** to a healthy provider.
- **Quality gate:** **100% catch-rate** of injected PII bad outputs, at **< 15 ms p99 added overhead** (measured ~14 ms; p50 ~7.5 ms) on the inline path.
- **Observability:** every request traceable end-to-end in the dashboard.

(X / Y are filled with real measured numbers at the load-test phase — see `ROADMAP.md` Phase 7.)
These are real measured numbers from the load harness (`pnpm load` → `load/RESULTS.md`). Honest framing: overhead is Sentinel's *own* per-request time against a near-instant mock upstream (not a model's latency); cost-reduction is on a 50%-repeat workload (real savings track your traffic's repeat rate); the quality catch-rate is the **deterministic guardrail** rate — the async LLM judge needs a real model and is covered by the Phase 5 unit tests.

## 6. Scope boundaries (explicit)

Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Sentinel

[![CI](https://github.com/MANVENDRA-github/sentinel/actions/workflows/ci.yml/badge.svg)](https://github.com/MANVENDRA-github/sentinel/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Node](https://img.shields.io/badge/node-%E2%89%A522-339933.svg)](./package.json)

A self-hostable **verifying LLM gateway** — a drop-in, OpenAI-compatible proxy that **routes** (cheapest capable model + fallback), **semantically caches**, and **verifies** (deterministic guardrails inline + a local Ollama judge) every LLM call, with full OpenTelemetry tracing. Unlike after-the-fact observability tools, it can flag or block a bad response _before it returns_.

> 🚧 **Early development.** Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md). The gateway is feature-complete (proxy, tracing, cache, routing, verification) and now ships a **Phase 6** dashboard; hardening + launch (Phase 7) is next.
> **v0.1.0 — feature-complete and hardened.** All seven build phases are done: drop-in proxy, OpenTelemetry tracing, semantic cache, routing / fallback / rate-limit survival, in-path verification (guardrails + a local judge), a React dashboard, and security hardening with a reproducible load harness. Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md).

## What works today (Phase 1)
## What it does

A drop-in, OpenAI-compatible `POST /v1/chat/completions` endpoint (streaming and non-streaming) that authenticates callers, validates requests, and forwards them to **any OpenAI-compatible provider** you configure — OpenAI, Groq, Mistral, OpenRouter, DeepSeek, xAI, Google Gemini (via its OpenAI endpoint), or a local **Ollama** model. Point your existing OpenAI SDK at Sentinel by changing one line: the base URL.

Expand Down Expand Up @@ -129,19 +133,43 @@ Verdicts are queryable: `GET /traces?guardrailStatus=block`, `?judgeScoreMax=2`,

A read-only **React + Vite** dashboard (`packages/dashboard`) visualizes the trace API: request volume over time, error / cache-hit / fallback rates, latency p95, token usage, provider / model / status distributions, a judge-score histogram, guardrail breakdown, a recent-requests table, and quality regressions. It aggregates client-side from `GET /traces` + `GET /regression`, so it needs only your gateway's `SENTINEL_ADMIN_KEY`.

![The Sentinel dashboard — trace volume over time, latency and error / cache-hit / fallback rates, token usage, provider and model distributions, judge-score histogram, and guardrail breakdown.](./docs/dashboard.png)

```bash
pnpm dev # gateway on :8080 (set SENTINEL_ADMIN_KEY to enable the admin API)
pnpm dev:dashboard # dashboard on :5173 (dev-proxies the admin API to :8080)
```

Open <http://localhost:5173>, paste your admin key, and hit Refresh. The dashboard is end-to-end tested with Playwright (`pnpm test:e2e`).

## Benchmarks

Real numbers from the bundled load harness (`pnpm load` spins up a mock upstream and the gateway in-process and drives the cache / fallback / guardrail paths — full method and honest caveats in [`load/RESULTS.md`](./load/RESULTS.md)):

| Metric | Result |
|---|---|
| **Cost reduction** (50%-repeat workload) | **50%** — 100 of 200 requests served from the semantic cache with zero upstream calls |
| **Unhandled 429s** to the client | **0** — all 50 requests to an always-429 upstream were retried and failed over to a healthy provider |
| **Guardrail catch-rate** (injected PII) | **100%** — 50 of 50 bad responses blocked in-path |
| **Added overhead** (Sentinel's own time) | **~14 ms p99** (p50 ~7.5 ms) against a near-instant mock upstream |

Framing, stated plainly: overhead is Sentinel's _own_ per-request cost, not a model's latency; cost-reduction scales with your traffic's repeat rate; and the catch-rate is the deterministic guardrail rate — the async LLM judge needs a real model and is covered by the unit tests. Reproduce it all with `pnpm load`.

## Development

```bash
pnpm verify # typecheck + lint + tests with coverage (the pre-PR gate)
pnpm test:watch # tests in watch mode
pnpm dev # run the gateway with reload
pnpm load # run the load harness → load/RESULTS.md
```

See [`CLAUDE.md`](./CLAUDE.md) for the full command list, coding standards, and contribution workflow, and [`ROADMAP.md`](./ROADMAP.md) for what's next.

## Contributing

Contributions are welcome. Start with [`CLAUDE.md`](./CLAUDE.md) — it documents the architecture, the coding standards (strict TypeScript, named exports, the `pnpm verify` gate), the security-review discipline ([`SECURITY_REVIEW_LOG.md`](./SECURITY_REVIEW_LOG.md)), and the phase-based workflow. For anything non-trivial, open an issue to discuss it before a large PR.

## License

[MIT](./LICENSE) © 2026 Manvendra
22 changes: 12 additions & 10 deletions SECURITY_REVIEW_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove
### 1. Secret / key management

- [x] Provider keys loaded only from env/secret store; never hard-coded, never in the repo.
- [ ] Keys never written to logs, traces, error messages, or the dashboard (redaction tested).
- [x] Keys never written to logs, traces, error messages, or the dashboard (redaction tested).
- [x] Client-facing errors never echo upstream auth headers.
- [x] `.env`/secrets in `.gitignore`; `.env.example` has placeholders only.

Expand All @@ -28,13 +28,13 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove
### 3. AuthN / AuthZ / tenant isolation

- [x] Every request authenticated by a Sentinel API key; invalid/missing ⇒ 401.
- [ ] Cache entries and traces namespaced per key; cross-tenant read is impossible (tested).
- [x] Cache entries and traces namespaced per key; cross-tenant read is impossible (tested).
- [x] Admin/config endpoints separated from proxy traffic and authorized distinctly.

### 4. SSRF / outbound calls

- [x] Provider base URLs come from a config allow-list, not from request input.
- [ ] Redirects to other hosts are not blindly followed.
- [x] Redirects to other hosts are not blindly followed.

### 5. Cache poisoning / data leakage

Expand All @@ -44,23 +44,23 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove
### 6. Log / trace data hygiene

- [x] Traces are metadata-only — prompt/response bodies are never persisted; API keys stored as a SHA-256 hash, never raw.
- [ ] Stored trace content is escaped on render in the dashboard (no stored XSS).
- [x] Stored trace content is escaped on render in the dashboard (no stored XSS).

### 7. Availability / DoS

- [ ] Sentinel's own rate-limits/quotas protect it from a runaway client.
- [x] Sentinel's own rate-limits/quotas protect it from a runaway client.
- [x] Bounded queues/timeouts; a slow provider cannot exhaust the event loop or memory.

### 8. Supply chain

- [ ] Dependencies pinned; `pnpm audit` clean (or triaged) in CI.
- [ ] No post-install scripts from untrusted packages.
- [x] Dependencies pinned; `pnpm audit` clean (or triaged) in CI.
- [x] No post-install scripts from untrusted packages.

## Pre-launch (Phase 7) gate

- [ ] All high-risk items above ticked, with tests.
- [ ] `pnpm audit` triaged; secrets scan clean.
- [ ] An adversarial review pass with **fresh context** — re-derive the attack, don't trust the author's framing (the cold-gatekeeper pattern).
- [x] All high-risk items above ticked, with tests.
- [x] `pnpm audit` triaged; secrets scan clean.
- [x] An adversarial review pass with **fresh context** — Phase 7 ran two independent cold-context Explore audits that re-derived each box's defense (or gap) directly from the code, with file:line citations, rather than trusting the author's framing.

## Review log

Expand All @@ -73,4 +73,6 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove
| SR-004 | 2026-06-27 | 7 | Phase 4 routing & fallback | Self-inflicted DoS: unbounded retries/fallback amplify load; a slow or hostile provider stalls the event loop; throttle/router as a new untrusted-input path | med | mitigated | Each attempt is bounded by a per-attempt `AbortController` timeout (`REQUEST_TIMEOUT_MS`) so a slow provider can't hang the loop (tested). Retries are capped (`MAX_RETRIES`) and fallback walks a finite, **config-defined** candidate chain — request input never adds providers/URLs (no new SSRF surface). A per-provider token bucket paces outbound calls to each provider's `rpm`, protecting upstreams and avoiding self-induced 429 storms. Terminal 4xx errors fail fast without amplification. Inbound per-client rate-limiting (box 7.1) is still deferred. |
| SR-005 | 2026-06-27 | 2 | Phase 5 verification (guardrails + judge) | Prompt-injection via response content talking the judge into a "pass"; PII/secrets leaking into traces via verdicts; request content disabling guardrails | high | mitigated | Boxes 2.1–2.3 ticked (tested). Guardrails/judge treat prompt+response as **data**, never instructions — only regex/JSON inspection, no eval. The judge prompt wraps the response behind explicit delimiters and labels it untrusted DATA; the verdict is parsed solely from the judge's own JSON output, so an injected `{"score":5}` in the response can't set the score (tested); a judge failure ⇒ "unscored" (null), **never** a pass. Guardrail policy comes only from env/config file, never the request body. Traces persist violation **category codes** (`pii.email`) — never the matched PII/secret value. Inline guardrails fail **closed**: a check that throws blocks. |

| SR-006 | 2026-06-27 | 1, 4, 7, 8 | Phase 7 hardening & launch | Key leakage to logs; SSRF via upstream redirect; self-inflicted DoS from a runaway client; supply-chain advisories | high | mitigated | Box 1.2: `logRedaction` redacts `authorization`/`x-api-key`; a test proves the header logs as `[redacted]` and the raw key never appears (Fastify's default serializer also omits headers, so keys can't leak by default). Box 4.2: the upstream `fetch` uses `redirect: 'error'` — a malicious provider can't 3xx-redirect the prompt to another host (tested). Box 7.1: a per-API-key inbound token bucket (`CLIENT_RPM`) returns 429 when one key exceeds its budget, without affecting other clients (tested). Box 8.1: CI runs `pnpm audit --prod --audit-level=high` and production deps are clean; the only advisories are dev-only (vitest/vite test tooling, never shipped), triaged and tracked for a vitest 3 upgrade. Boxes 3.2/6.2/8.2 ticked from existing coverage (cache per-tenant isolation tests; React-escaped, metadata-only dashboard; no untrusted post-install scripts). |

> Add a row per high-risk change. Status ∈ {open, mitigated, accepted}. Severity ∈ {low, med, high, critical}.
Binary file added docs/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
{ ignores: ['**/dist/**', '**/coverage/**', '**/node_modules/**'] },
{ ignores: ['**/dist/**', '**/coverage/**', '**/node_modules/**', 'load/**'] },
js.configs.recommended,
{
files: ['**/*.ts'],
Expand Down
22 changes: 22 additions & 0 deletions load/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Load test results

In-process load (Fastify `inject`) against mock upstreams — isolates Sentinel's own
behavior and overhead without a real LLM. Reproduce with `pnpm load`.

| Metric | Value |
|---|---|
| Total requests | 300 |
| Upstream chat calls (mock) | 200 of 300 requests (cache served 100 from memory) |
| Cache cost-reduction (50%-repeat workload) | 50.0% (100/200 served from cache) |
| Gateway overhead p50 / p99 | 7.51 ms / 14.44 ms |
| Client latency p99 | 33.93 ms |
| 429-elimination | 0 unhandled 429s to client (of 50 always-429 upstream); 50 succeeded via fallback |
| Fallback used (traces) | 50 |
| Guardrail catch-rate (injected PII) | 100% (50/50 blocked) |

## Framing (honest)

- **Overhead** is Sentinel's own per-request time — the mock upstream is ~instant, so duration ≈ the gateway's work. Not a model's latency.
- **Cost-reduction** is measured on a workload with 50% exact-repeat prompts (the cache's target case). Real savings track your traffic's repeat rate.
- **429-elimination**: every request to a flaky (always-429) provider was retried and **failed over** to a healthy provider, so the client never saw an unhandled 429.
- **Catch-rate** is the **deterministic guardrail** rate on injected PII responses; the async LLM judge (needs a real model) is covered by the Phase 5 unit tests, not here.
15 changes: 15 additions & 0 deletions load/load.sentinel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"providers": {
"reliable": { "type": "openai-compatible", "baseUrl": "http://localhost:8082" },
"flaky": { "type": "openai-compatible", "baseUrl": "http://localhost:8081" }
},
"models": {
"std": "reliable",
"pii": "reliable",
"svc": "flaky",
"warmup": "reliable"
},
"defaultProvider": "reliable",
"routing": { "fallback": ["std"] },
"guardrails": { "pii": ["pii.email"] }
}
Loading
Loading