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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to Sentinel are documented here. The format follows

## [Unreleased]

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

### Fixed

- The gateway production build (`pnpm build`) no longer pulls dashboard sources into the Node build; CI now runs `pnpm build` on every PR so it can't silently break again.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Sentinel never hardcodes a machine or a model. Set `OLLAMA_BASE_URL` in `.env` t

## Observability

Every request is traced with OpenTelemetry — provider, model, status, latency, and token usage — and persisted to a queryable store (SQLite by default; set `TRACE_DB=memory` for an ephemeral one). Spans also export to any OTLP collector (Jaeger, etc.) when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.
Every request is traced with OpenTelemetry — provider, model, status, latency, token usage, and (when a `pricing` map is configured) estimated USD cost — and persisted to a queryable store (SQLite by default; set `TRACE_DB=memory` for an ephemeral one). Spans also export to any OTLP collector (Jaeger, etc.) when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.

Read recent traces through the admin-gated API (set `SENTINEL_ADMIN_KEY` to enable it):

Expand Down Expand Up @@ -164,7 +164,7 @@ Sentinel v0.1.0 is a **single-node, self-hosted** gateway. Honest boundaries tod
- **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.
- **Cost reduction is measured by request volume** (cache hits × avoided upstream calls) on a repeat-heavy workload; per-request dollar accounting 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
Binary file modified 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.
15 changes: 12 additions & 3 deletions packages/dashboard/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function sampleTrace(over: Record<string, unknown>): Record<string, unknown> {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
costUsd: null,
errorType: null,
errorMessage: null,
apiKeyHash: null,
Expand All @@ -32,9 +33,16 @@ function sampleTrace(over: Record<string, unknown>): Record<string, unknown> {
}

const traces = [
sampleTrace({ id: 'a', status: 200, cacheHit: true, judgeScore: 5 }),
sampleTrace({ id: 'b', status: 500, errorType: 'upstream_error' }),
sampleTrace({ id: 'c', status: 200, provider: 'groq', fallbackUsed: true, judgeScore: 3 }),
sampleTrace({ id: 'a', status: 200, cacheHit: true, judgeScore: 5, costUsd: 0.01 }),
sampleTrace({ id: 'b', status: 500, errorType: 'upstream_error', costUsd: 0.02 }),
sampleTrace({
id: 'c',
status: 200,
provider: 'groq',
fallbackUsed: true,
judgeScore: 3,
costUsd: 0.03,
}),
];

test('renders aggregated stats from the trace API', async ({ page }) => {
Expand All @@ -54,4 +62,5 @@ test('renders aggregated stats from the trace API', async ({ page }) => {
await expect(page.getByTitle('groq')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Recent requests' })).toBeVisible();
await expect(page.getByTestId('stat-judge')).toContainText('4'); // mean of [5,3]
await expect(page.getByTestId('stat-cost')).toContainText('$0.05'); // 0.02 + 0.03 (cache hit excluded)
});
2 changes: 2 additions & 0 deletions packages/dashboard/e2e/screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function trace(over: Record<string, unknown>): Record<string, unknown> {
promptTokens: 40,
completionTokens: 20,
totalTokens: 60,
costUsd: null,
errorType: null,
errorMessage: null,
apiKeyHash: null,
Expand Down Expand Up @@ -96,6 +97,7 @@ function buildTraces(): Record<string, unknown>[] {
promptTokens: prompt,
completionTokens: completion,
totalTokens: prompt + completion,
costUsd: Math.round((prompt * 0.00002 + completion * 0.00006) * 1e6) / 1e6,
cacheHit,
fallbackUsed,
routedProvider: fallbackUsed ? 'ollama' : null,
Expand Down
11 changes: 11 additions & 0 deletions packages/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ function pct(value: number): string {
return `${String(Math.round(value * 1000) / 10)}%`;
}

function usd(value: number): string {
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`;
}

export function App() {
const [baseUrl, setBaseUrl] = useState(() => localStorage.getItem(KEY_BASE) ?? '');
const [adminKey, setAdminKey] = useState(() => localStorage.getItem(KEY_ADMIN) ?? '');
Expand Down Expand Up @@ -124,6 +128,12 @@ export function App() {
sub={`avg ${String(stats.avgLatencyMs)} ms`}
/>
<StatCard testId="stat-tokens" label="Tokens" value={stats.totalTokens.toLocaleString()} />
<StatCard
testId="stat-cost"
label="Cost"
value={usd(stats.totalCostUsd)}
sub={`${usd(stats.savedCostUsd)} saved by cache`}
/>
<StatCard
testId="stat-judge"
label="Avg judge score"
Expand All @@ -134,6 +144,7 @@ export function App() {

<section className="grid">
<Sparkline title="Requests over time" buckets={stats.overTime} />
<Sparkline title="Cost over time (USD)" buckets={stats.overTime} value={(b) => b.costUsd} />
<BarChart title="By provider" data={stats.byProvider} />
<BarChart title="By model" data={stats.byModel} />
<BarChart title="By status" data={stats.byStatusClass} />
Expand Down
24 changes: 24 additions & 0 deletions packages/dashboard/src/aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function trace(over: Partial<TraceRecord>): TraceRecord {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
costUsd: null,
errorType: null,
errorMessage: null,
apiKeyHash: null,
Expand Down Expand Up @@ -91,4 +92,27 @@ describe('computeStats', () => {
expect(stats.overTime[0]?.count).toBe(2);
expect(stats.overTime[1]?.count).toBe(1);
});

it('sums cost spent vs saved by cache (null cost ignored)', () => {
const stats = computeStats([
trace({ costUsd: 0.01, cacheHit: false }),
trace({ costUsd: 0.02, cacheHit: false }),
trace({ costUsd: 0.05, cacheHit: true }),
trace({ costUsd: null, cacheHit: false }),
]);
expect(stats.totalCostUsd).toBe(0.03);
expect(stats.savedCostUsd).toBe(0.05);
});

it('buckets cost over time, excluding cache hits', () => {
const stats = computeStats(
[
trace({ timestamp: 0, costUsd: 0.01, cacheHit: false }),
trace({ timestamp: 30_000, costUsd: 0.02, cacheHit: false }),
trace({ timestamp: 30_000, costUsd: 0.04, cacheHit: true }),
],
60_000,
);
expect(stats.overTime[0]?.costUsd).toBeCloseTo(0.03, 6);
});
});
18 changes: 17 additions & 1 deletion packages/dashboard/src/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface TimeBucket {
count: number;
tokens: number;
errors: number;
/** Actual upstream spend in this window (USD; cache hits excluded). */
costUsd: number;
}

export interface ScoreBin {
Expand All @@ -26,6 +28,10 @@ export interface Stats {
fallbacks: number;
fallbackRate: number;
totalTokens: number;
/** Actual upstream spend (USD) over non-cache-hit requests. */
totalCostUsd: number;
/** Spend avoided by cache hits (USD). */
savedCostUsd: number;
avgLatencyMs: number;
p95LatencyMs: number;
judgeScoredCount: number;
Expand All @@ -49,6 +55,8 @@ export const EMPTY_STATS: Stats = {
fallbacks: 0,
fallbackRate: 0,
totalTokens: 0,
totalCostUsd: 0,
savedCostUsd: 0,
avgLatencyMs: 0,
p95LatencyMs: 0,
judgeScoredCount: 0,
Expand Down Expand Up @@ -97,6 +105,8 @@ export function computeStats(traces: TraceRecord[], bucketMs = 60_000): Stats {
let cacheHits = 0;
let fallbacks = 0;
let totalTokens = 0;
let totalCostUsd = 0;
let savedCostUsd = 0;
const latencies: number[] = [];
const judgeScores: number[] = [];
const histogram = new Map<number, number>([
Expand All @@ -114,16 +124,20 @@ export function computeStats(traces: TraceRecord[], bucketMs = 60_000): Stats {
if (t.cacheHit) cacheHits++;
if (t.fallbackUsed) fallbacks++;
totalTokens += t.totalTokens ?? 0;
const cost = t.costUsd ?? 0;
if (t.cacheHit) savedCostUsd += cost;
else totalCostUsd += cost;
latencies.push(t.durationMs);
if (t.judgeScore !== null) {
judgeScores.push(t.judgeScore);
const rounded = Math.round(t.judgeScore);
if (histogram.has(rounded)) histogram.set(rounded, (histogram.get(rounded) ?? 0) + 1);
}
const key = Math.floor(t.timestamp / bucketMs) * bucketMs;
const bucket = buckets.get(key) ?? { bucket: key, count: 0, tokens: 0, errors: 0 };
const bucket = buckets.get(key) ?? { bucket: key, count: 0, tokens: 0, errors: 0, costUsd: 0 };
bucket.count++;
bucket.tokens += t.totalTokens ?? 0;
if (!t.cacheHit) bucket.costUsd += cost;
if (isError) bucket.errors++;
buckets.set(key, bucket);
}
Expand All @@ -143,6 +157,8 @@ export function computeStats(traces: TraceRecord[], bucketMs = 60_000): Stats {
fallbacks,
fallbackRate: fallbacks / total,
totalTokens,
totalCostUsd: Math.round(totalCostUsd * 1e6) / 1e6,
savedCostUsd: Math.round(savedCostUsd * 1e6) / 1e6,
avgLatencyMs: Math.round(avgLatency * 100) / 100,
p95LatencyMs: Math.round(percentile(latencies, 95) * 100) / 100,
judgeScoredCount: judgeScores.length,
Expand Down
11 changes: 8 additions & 3 deletions packages/dashboard/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,20 @@ export function Histogram(props: { title: string; bins: ScoreBin[] }) {
);
}

export function Sparkline(props: { title: string; buckets: TimeBucket[] }) {
export function Sparkline(props: {
title: string;
buckets: TimeBucket[];
value?: (b: TimeBucket) => number;
}) {
const pts = props.buckets;
const value = props.value ?? ((b: TimeBucket) => b.count);
const width = 280;
const height = 60;
const max = Math.max(1, ...pts.map((p) => p.count));
const max = Math.max(1, ...pts.map((p) => value(p)));
const path = pts
.map((p, i) => {
const x = pts.length > 1 ? (i / (pts.length - 1)) * width : 0;
const y = height - (p.count / max) * height;
const y = height - (value(p) / max) * height;
return `${i === 0 ? 'M' : 'L'}${String(Math.round(x))},${String(Math.round(y))}`;
})
.join(' ');
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface TraceRecord {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
costUsd: number | null;
errorType: string | null;
errorMessage: string | null;
apiKeyHash: string | null;
Expand Down
20 changes: 20 additions & 0 deletions packages/gateway/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ describe('loadConfig', () => {
expect(cfg.defaultProvider).toBe('ollama');
});

it('parses the pricing map and defaults it to empty', () => {
const withPricing = JSON.stringify({
providers: { ollama: { type: 'openai-compatible', baseUrlEnv: 'OLLAMA_BASE_URL' } },
models: { 'llama3.2': 'ollama' },
pricing: { 'gpt-4o-mini': { inputPer1k: 0.15, outputPer1k: 0.6 } },
});
const cfg = loadConfig({ path: 'x', env, readFile: () => withPricing });
expect(cfg.pricing.get('gpt-4o-mini')).toEqual({ inputPer1k: 0.15, outputPer1k: 0.6 });
expect(loadConfig({ path: 'x', env, readFile: () => validConfig }).pricing.size).toBe(0);
});

it('rejects negative pricing', () => {
const bad = JSON.stringify({
providers: { ollama: { type: 'openai-compatible', baseUrlEnv: 'OLLAMA_BASE_URL' } },
models: {},
pricing: { m: { inputPer1k: -1, outputPer1k: 0 } },
});
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
11 changes: 11 additions & 0 deletions packages/gateway/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { z } from 'zod';
import { ConfigError } from './errors.js';
import type { ModelPricing } from './cost.js';

// ─────────────────────────── Server environment ───────────────────────────

Expand Down Expand Up @@ -129,13 +130,20 @@ const guardrailsConfigSchema = z.object({
requireJson: z.boolean().optional(),
});

// USD per 1,000 tokens, per model. Used to attribute a cost to every traced request.
const modelPricingSchema = z.object({
inputPer1k: z.number().nonnegative(),
outputPer1k: z.number().nonnegative(),
});

const sentinelConfigSchema = z
.object({
providers: z.record(z.string(), providerConfigSchema),
models: z.record(z.string(), z.string()),
defaultProvider: z.string().optional(),
routing: routingConfigSchema.optional(),
guardrails: guardrailsConfigSchema.optional(),
pricing: z.record(z.string(), modelPricingSchema).optional(),
})
.superRefine((cfg, ctx) => {
const names = new Set(Object.keys(cfg.providers));
Expand Down Expand Up @@ -183,6 +191,8 @@ export interface ResolvedConfig {
defaultProvider: string | undefined;
routing?: ResolvedRouting;
guardrails?: ResolvedGuardrails;
/** model name → USD-per-1K-token pricing (empty when no `pricing` block is configured). */
pricing: Map<string, ModelPricing>;
}

export interface LoadConfigOptions {
Expand Down Expand Up @@ -228,6 +238,7 @@ export function loadConfig(options: LoadConfigOptions): ResolvedConfig {
defaultProvider: parsed.data.defaultProvider,
...(parsed.data.routing ? { routing: parsed.data.routing } : {}),
...(parsed.data.guardrails ? { guardrails: parsed.data.guardrails } : {}),
pricing: new Map(Object.entries(parsed.data.pricing ?? {})),
};
}

Expand Down
52 changes: 52 additions & 0 deletions packages/gateway/src/cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { computeCostUsd } from './cost.js';
import type { ModelPricing } from './cost.js';

const pricing = new Map<string, ModelPricing>([
['gpt-4o-mini', { inputPer1k: 0.15, outputPer1k: 0.6 }],
['free-local', { inputPer1k: 0, outputPer1k: 0 }],
['dusty', { inputPer1k: 0.1, outputPer1k: 0.2 }],
]);

describe('computeCostUsd', () => {
it('prices input and output tokens from the per-1K rates', () => {
// 1000 prompt × 0.15/1K + 500 completion × 0.6/1K = 0.15 + 0.30 = 0.45
expect(
computeCostUsd('gpt-4o-mini', { promptTokens: 1000, completionTokens: 500 }, pricing),
).toBe(0.45);
});

it('returns null for a model that is not in the price map', () => {
expect(
computeCostUsd('mystery-model', { promptTokens: 100, completionTokens: 100 }, pricing),
).toBeNull();
});

it('returns null when no usage is available (both sides null)', () => {
expect(
computeCostUsd('gpt-4o-mini', { promptTokens: null, completionTokens: null }, pricing),
).toBeNull();
});

it('treats a missing side as zero when the other side is known', () => {
expect(
computeCostUsd('gpt-4o-mini', { promptTokens: 2000, completionTokens: null }, pricing),
).toBe(0.3);
expect(
computeCostUsd('gpt-4o-mini', { promptTokens: null, completionTokens: 1000 }, pricing),
).toBe(0.6);
});

it('returns 0 (not null) for a priced-but-free model with real usage', () => {
expect(
computeCostUsd('free-local', { promptTokens: 500, completionTokens: 500 }, pricing),
).toBe(0);
});

it('rounds away binary float dust (0.1 + 0.2)', () => {
// 1000/1K × 0.1 + 1000/1K × 0.2 = 0.30000000000000004 in IEEE-754 → rounded to 0.3
expect(computeCostUsd('dusty', { promptTokens: 1000, completionTokens: 1000 }, pricing)).toBe(
0.3,
);
});
});
Loading
Loading