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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to Sentinel are documented here. The format follows
### Added

- Per-request **cost tracking**: set a `pricing` map (USD per 1K tokens, per model) in `sentinel.config.json` and every trace records a `costUsd`; the dashboard shows total spend, spend saved by the cache, and cost over time.
- **Native Anthropic provider**: set `"type": "anthropic"` on a provider and Sentinel speaks Anthropic's Messages API (`x-api-key`, request/response/stream translation) while clients keep using the OpenAI shape.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The product source of truth is `PRP_SPEC.md`. The build order is `ROADMAP.md`. B
- Package manager / monorepo: **pnpm** workspaces.
- Gateway server: **Fastify** (streaming, hooks, `.inject()` testing).
- Schemas & validation: **Zod** at every trust boundary (config, request/response, guardrails).
- Providers: a unified, hand-rolled **OpenAI-compatible** HTTP adapter (`fetch`) — OpenAI, Groq, Gemini's OpenAI endpoint, Ollama, and other OpenAI-API providers. A native **Anthropic** (Messages API) adapter is on the near-term roadmap.
- Providers: a hand-rolled **OpenAI-compatible** HTTP adapter (`fetch`) — OpenAI, Groq, Gemini's OpenAI endpoint, Ollama, and other OpenAI-API providers — plus a native **Anthropic** (Messages API) adapter. Selected per provider by `type` in `sentinel.config.json`.
- Local models (keyless): **Ollama** — judge `qwen2.5:7b`, embeddings `nomic-embed-text`.
- Cache & rate-limit state: **in-memory** (single-node v1). A **Redis** (`ioredis`) backend is a planned swap behind the cache/throttle interfaces.
- Storage (traces, eval results): **better-sqlite3** / in-memory (single-node v1). A **Postgres** backend is a planned swap behind the `TraceStore` interface.
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A self-hostable **verifying LLM gateway** — a drop-in, OpenAI-compatible proxy

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

## Requirements

Expand Down Expand Up @@ -85,7 +85,7 @@ Each provider sets a `baseUrl` (or `baseUrlEnv` to read it from the environment)
}
```

A provider may also set an `rpm` (requests-per-minute) ceiling; the optional `routing` block configures cost-aware routing (see below). Because almost every provider speaks the OpenAI API, **bringing your own key is just config** — add a provider entry and a model route, drop the key in `.env`, done.
A provider may also set an `rpm` (requests-per-minute) ceiling; the optional `routing` block configures cost-aware routing (see below). Because almost every provider speaks the OpenAI API, **bringing your own key is just config** — add a provider entry and a model route, drop the key in `.env`, done. **Anthropic** is supported natively too: set `"type": "anthropic"` on the provider and Sentinel translates to/from its Messages API (clients keep sending the OpenAI shape).

### Bring your own Ollama

Expand Down Expand Up @@ -163,7 +163,6 @@ Sentinel v0.1.0 is a **single-node, self-hosted** gateway. Honest boundaries tod
- **SQLite (or in-memory) trace storage.** Ideal for single-node; a Postgres backend for multi-instance is planned.
- **The async judge needs a local Ollama** with `JUDGE_MODEL` pulled. Without it, judging degrades to `unscored` (never a false pass). The bundled benchmarks run against **mock upstreams**, so the headline catch-rate is the _deterministic guardrail_ rate; the LLM judge is covered by unit tests.
- **Inline guardrails apply to non-streaming responses.** Streamed responses are judged from their buffered output _after_ completion (inline blocking of a live stream is on the roadmap).
- **Providers are OpenAI-compatible.** OpenAI, Groq, Gemini's OpenAI endpoint, Ollama, and other OpenAI-API providers work today; a native **Anthropic** (Messages API) adapter is planned.
- **Per-request dollar cost requires a `pricing` map** in `sentinel.config.json` (USD per 1K tokens, per model). With it, every trace records a `costUsd` and the dashboard shows spend + cache savings; without it, cost is unknown (`null`) and only request-volume cache savings are visible.

## Development
Expand Down
2 changes: 2 additions & 0 deletions SECURITY_REVIEW_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,6 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove

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

| SR-007 | 2026-06-30 | 1, 4 | PR3 native Anthropic adapter | A second outbound auth path (`x-api-key`) and a new response/stream shape: risk of leaking the Anthropic key to logs, following a malicious redirect, or trusting a malformed upstream body | high | mitigated | The Anthropic key is sent only in the outbound `x-api-key` header — never logged (`logRedaction` already redacts inbound `x-api-key`; Fastify omits request headers by default) and never returned to clients; it comes from `ANTHROPIC_API_KEY` via `apiKeyEnv`, never hard-coded. The adapter reuses `redirect: 'error'`, so a malicious provider can't 3xx the prompt to another host (tested), and the base URL still comes only from the config allow-list, never request input. The Anthropic response is parsed through a Zod schema before translation; an unexpected shape ⇒ `UpstreamError` (502), never a malformed pass-through (tested). |

> Add a row per high-risk change. Status ∈ {open, mitigated, accepted}. Severity ∈ {low, med, high, critical}.
28 changes: 28 additions & 0 deletions packages/gateway/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ describe('loadConfig', () => {
expect(() => loadConfig({ path: 'x', env, readFile: () => bad })).toThrow(ConfigError);
});

it('resolves the anthropic provider adapter type', () => {
const cfg = JSON.stringify({
providers: {
claude: {
type: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
apiKeyEnv: 'ANTHROPIC_API_KEY',
},
},
models: { 'claude-3-5-sonnet': 'claude' },
});
const resolved = loadConfig({
path: 'x',
env: { ANTHROPIC_API_KEY: 'sk-ant' },
readFile: () => cfg,
});
expect(resolved.providers.get('claude')?.type).toBe('anthropic');
expect(resolved.providers.get('claude')?.apiKey).toBe('sk-ant');
});

it('rejects an unknown provider adapter type', () => {
const bad = JSON.stringify({
providers: { p: { type: 'cohere', baseUrl: 'https://x' } },
models: {},
});
expect(() => loadConfig({ path: 'x', env, readFile: () => bad })).toThrow(ConfigError);
});

it('throws on invalid JSON', () => {
expect(() => loadConfig({ path: 'x', env, readFile: () => '{ not json' })).toThrow(ConfigError);
});
Expand Down
12 changes: 10 additions & 2 deletions packages/gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function loadServerEnv(env: NodeJS.ProcessEnv): ServerEnv {

const providerConfigSchema = z
.object({
type: z.literal('openai-compatible'),
type: z.enum(['openai-compatible', 'anthropic']),
baseUrl: z.string().url().optional(),
baseUrlEnv: z.string().min(1).optional(),
apiKeyEnv: z.string().min(1).optional(),
Expand Down Expand Up @@ -165,6 +165,8 @@ const sentinelConfigSchema = z

export interface ResolvedProvider {
name: string;
/** Which adapter serves this provider: a generic OpenAI-compatible HTTP API or Anthropic's Messages API. */
type: 'openai-compatible' | 'anthropic';
baseUrl: string;
apiKey: string | undefined;
/** Per-provider requests-per-minute limit for the throttle (omitted = unlimited). */
Expand Down Expand Up @@ -229,7 +231,13 @@ export function loadConfig(options: LoadConfigOptions): ResolvedConfig {
for (const [name, p] of Object.entries(parsed.data.providers)) {
const baseUrl = p.baseUrl ?? readEnvOrThrow(options.env, p.baseUrlEnv, name);
const apiKey = p.apiKeyEnv === undefined ? undefined : options.env[p.apiKeyEnv];
providers.set(name, { name, baseUrl, apiKey, ...(p.rpm !== undefined ? { rpm: p.rpm } : {}) });
providers.set(name, {
name,
type: p.type,
baseUrl,
apiKey,
...(p.rpm !== undefined ? { rpm: p.rpm } : {}),
});
}

return {
Expand Down
226 changes: 226 additions & 0 deletions packages/gateway/src/providers/anthropic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, it, expect, vi } from 'vitest';
import { createAnthropicProvider } from './anthropic.js';
import { UpstreamError } from '../errors.js';
import type { ChatCompletionRequest } from '../schemas.js';

const request: ChatCompletionRequest = {
model: 'claude-3-5-sonnet',
messages: [
{ role: 'system', content: 'Be brief.' },
{ role: 'user', content: 'hi' },
],
max_tokens: 100,
temperature: 0.5,
};

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}

function streamResponse(chunks: string[]): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
return new Response(body, { status: 200 });
}

const reply = {
id: 'msg_1',
type: 'message',
role: 'assistant',
model: 'claude-3-5-sonnet',
content: [{ type: 'text', text: 'Hello there' }],
stop_reason: 'end_turn',
usage: { input_tokens: 12, output_tokens: 5 },
};

describe('anthropic provider', () => {
it('translates an OpenAI request into an Anthropic Messages request', async () => {
const fetchImpl = vi.fn(
async (_url: string, _init: { headers: Record<string, string>; body: string }) =>
Promise.resolve(jsonResponse(reply)),
);
const provider = createAnthropicProvider({
name: 'anthropic',
baseUrl: 'http://h/v1/',
apiKey: 'key',
fetchImpl,
});

await provider.chat(request);

const call = fetchImpl.mock.calls[0]!;
expect(call[0]).toBe('http://h/v1/messages');
expect(call[1].headers['x-api-key']).toBe('key');
expect(call[1].headers['anthropic-version']).toBe('2023-06-01');
expect(call[1].headers.authorization).toBeUndefined();
const body = JSON.parse(call[1].body) as {
model: string;
max_tokens: number;
system?: string;
temperature?: number;
messages: { role: string; content: string }[];
stream?: boolean;
};
expect(body.model).toBe('claude-3-5-sonnet');
expect(body.max_tokens).toBe(100);
expect(body.system).toBe('Be brief.'); // system message hoisted out of messages
expect(body.temperature).toBe(0.5);
expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
expect(body.stream).toBeUndefined();
});

it('maps the Anthropic response into the OpenAI chat-completion shape', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse(reply)));
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });

const result = (await provider.chat(request)) as {
object: string;
model: string;
choices: { message: { role: string; content: string }; finish_reason: string | null }[];
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
};
expect(result.object).toBe('chat.completion');
expect(result.model).toBe('claude-3-5-sonnet');
expect(result.choices[0]?.message).toEqual({ role: 'assistant', content: 'Hello there' });
expect(result.choices[0]?.finish_reason).toBe('stop'); // end_turn → stop
expect(result.usage).toEqual({ prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 });
});

it('defaults max_tokens when the caller omits it, and maps max_tokens → length', async () => {
const fetchImpl = vi.fn(
async (_url: string, _init: { headers: Record<string, string>; body: string }) =>
Promise.resolve(jsonResponse({ ...reply, stop_reason: 'max_tokens' })),
);
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });

const result = (await provider.chat({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
})) as { choices: { finish_reason: string | null }[] };

const body = JSON.parse(fetchImpl.mock.calls[0]![1].body) as { max_tokens: number };
expect(body.max_tokens).toBe(4096);
expect(result.choices[0]?.finish_reason).toBe('length');
});

it('omits x-api-key when no key is set', async () => {
const fetchImpl = vi.fn(
async (_url: string, _init: { headers: Record<string, string>; body: string }) =>
Promise.resolve(jsonResponse(reply)),
);
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
await provider.chat(request);
expect(fetchImpl.mock.calls[0]![1].headers['x-api-key']).toBeUndefined();
});

it('throws UpstreamError on a non-OK response', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(new Response('overloaded', { status: 529 })));
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
await expect(provider.chat(request)).rejects.toBeInstanceOf(UpstreamError);
});

it('throws UpstreamError when the response shape is unexpected', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse({ content: 'not-an-array' })));
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
await expect(provider.chat(request)).rejects.toBeInstanceOf(UpstreamError);
});

it('translates an Anthropic SSE stream into OpenAI chunks', async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(
streamResponse([
'event: message_start\ndata: {"type":"message_start","message":{"id":"m"}}\n\n',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}\n\n',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}\n\n',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n',
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
]),
),
);
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });

const chunks: { choices: { delta: { content?: string }; finish_reason: string | null }[] }[] =
[];
for await (const payload of provider.chatStream(request)) {
chunks.push(JSON.parse(payload));
}
// message_start and message_stop are dropped; two text deltas + one finish chunk remain.
expect(chunks).toHaveLength(3);
const text = chunks.map((c) => c.choices[0]?.delta.content ?? '').join('');
expect(text).toBe('Hello');
expect(chunks[2]?.choices[0]?.finish_reason).toBe('stop');
});

it('throws UpstreamError when a streaming response has no body', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })));
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
await expect(
(async () => {
for await (const _chunk of provider.chatStream(request)) {
// drain
}
})(),
).rejects.toBeInstanceOf(UpstreamError);
});

it('flattens array content, joins system blocks, and forwards top_p / stop', async () => {
const fetchImpl = vi.fn(
async (_url: string, _init: { headers: Record<string, string>; body: string }) =>
Promise.resolve(jsonResponse(reply)),
);
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
await provider.chat({
model: 'claude-3-5-sonnet',
messages: [
{ role: 'system', content: 'A' },
{ role: 'system', content: 'B' },
{ role: 'user', content: [{ type: 'text', text: 'multi' }, { type: 'image' }] },
{ role: 'assistant', content: 'ok' },
{ role: 'tool', content: 'treated-as-user' },
],
top_p: 0.9,
stop: ['STOP'],
} as ChatCompletionRequest);

const body = JSON.parse(fetchImpl.mock.calls[0]![1].body) as {
system: string;
top_p: number;
stop_sequences: string[];
messages: { role: string; content: string }[];
};
expect(body.system).toBe('A\n\nB'); // multiple system blocks joined
expect(body.top_p).toBe(0.9);
expect(body.stop_sequences).toEqual(['STOP']);
expect(body.messages).toEqual([
{ role: 'user', content: 'multi' }, // text block extracted; image block dropped
{ role: 'assistant', content: 'ok' },
{ role: 'user', content: 'treated-as-user' }, // non-system/assistant → user
]);
});

it('skips keep-alives, unknown events, and non-text deltas in the stream', async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(
streamResponse([
': ping\n\n', // comment, no data line
'event: ping\ndata: {"type":"ping"}\n\n', // unknown type
'event: content_block_start\ndata: {"type":"content_block_start","index":0}\n\n', // not a delta
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"input_json_delta"}}\n\n', // non-text delta
'event: content_block_delta\ndata: not-json\n\n', // unparseable
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n',
]),
),
);
const provider = createAnthropicProvider({ name: 'anthropic', baseUrl: 'http://h', fetchImpl });
const out: string[] = [];
for await (const payload of provider.chatStream(request)) out.push(payload);
expect(out).toHaveLength(1);
const chunk = JSON.parse(out[0]!) as { choices: { delta: { content?: string } }[] };
expect(chunk.choices[0]?.delta.content).toBe('hi');
});
});
Loading
Loading