feat: add cache control settings and token-based metrics

Settings:
- Add `alwaysPreserveClientCache` setting with modes: auto/always/never
- UI toggle in Dashboard > Settings > Routing tab
- Auto mode preserves cache_control for Claude Code clients with deterministic routing

Metrics:
- Track prompt cache token usage (input, cached, creation)
- Display cache reuse ratio (cached/input tokens)
- Breakdown by provider and routing strategy
- Shows tokens saved and estimated cost savings

API Endpoints:
- GET /api/settings/cache-metrics - retrieve metrics
- DELETE /api/settings/cache-metrics - reset metrics

Files:
- open-sse/utils/cacheControlPolicy.ts: CacheControlMetrics interface, trackCacheMetrics, updateCacheTokenMetrics
- open-sse/handlers/chatCore.ts: Track cache tokens from provider responses
- src/lib/db/settings.ts: Database functions for metrics persistence
- src/lib/cacheControlSettings.ts: Cached settings accessor
- src/app/(dashboard)/dashboard/settings/components/CacheStatsCard.tsx: Metrics dashboard UI
- tests/unit/*.test.mjs: Unit tests (41 tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tombii
2026-03-29 14:37:55 +02:00
parent 0bc557fb8b
commit 26f7b36ce4
11 changed files with 1226 additions and 40 deletions

View File

@@ -4,69 +4,190 @@ import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
interface CacheMetrics {
totalRequests: number;
requestsWithCacheControl: number;
totalInputTokens: number;
totalCachedTokens: number;
totalCacheCreationTokens: number;
tokensSaved: number;
estimatedCostSaved: number;
byProvider: Record<
string,
{
requests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
>;
byStrategy: Record<
string,
{
requests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
>;
lastUpdated: string;
}
export default function CacheStatsCard() {
const [cache, setCache] = useState(null);
const [flushing, setFlushing] = useState(false);
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
const [resetting, setResetting] = useState(false);
const t = useTranslations("settings");
const fetchStats = () => {
fetch("/api/cache/stats")
const fetchMetrics = () => {
fetch("/api/settings/cache-metrics")
.then((r) => r.json())
.then(setCache)
.then(setMetrics)
.catch(() => {});
};
useEffect(fetchStats, []);
useEffect(fetchMetrics, []);
const handleFlush = async () => {
setFlushing(true);
const handleReset = async () => {
setResetting(true);
try {
await fetch("/api/cache/stats", { method: "DELETE" });
fetchStats();
await fetch("/api/settings/cache-metrics", { method: "DELETE" });
fetchMetrics();
} finally {
setFlushing(false);
setResetting(false);
}
};
const cacheHitRate =
metrics && metrics.totalInputTokens > 0
? (metrics.totalCachedTokens / metrics.totalInputTokens) * 100
: 0;
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">cached</span>
{t("promptCache")}
<span className="material-symbols-outlined text-[20px]">insights</span>
Prompt Cache Metrics
</h3>
<button
onClick={handleFlush}
disabled={flushing}
onClick={handleReset}
disabled={resetting}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{flushing ? t("flushing") : t("flushCache")}
{resetting ? "Resetting..." : "Reset Metrics"}
</button>
</div>
{cache ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">{t("size")}</p>
<p className="font-mono text-lg text-text-main">
{cache.size}/{cache.maxSize}
</p>
{metrics ? (
<div className="space-y-4">
{/* Overview Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Total Requests</p>
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
</div>
<div>
<p className="text-text-muted">With Cache Control</p>
<p className="font-mono text-lg text-text-main">{metrics.requestsWithCacheControl}</p>
</div>
</div>
<div>
<p className="text-text-muted">{t("hitRate")}</p>
<p className="font-mono text-lg text-text-main">{cache.hitRate?.toFixed(1) ?? 0}%</p>
{/* Token Stats */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-text-muted">Input Tokens</p>
<p className="font-mono text-lg text-text-main">
{metrics.totalInputTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Cached Tokens (Read)</p>
<p className="font-mono text-lg text-green-400">
{metrics.totalCachedTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Cache Creation (Write)</p>
<p className="font-mono text-lg text-blue-400">
{metrics.totalCacheCreationTokens.toLocaleString()}
</p>
</div>
</div>
<div>
<p className="text-text-muted">{t("hits")}</p>
<p className="font-mono text-text-main">{cache.hits ?? 0}</p>
{/* Cache Ratio */}
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-main">Cache Reuse Ratio</p>
<p className="text-xs text-text-muted">Cached tokens / Total input tokens</p>
</div>
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
</div>
{/* Progress bar */}
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
/>
</div>
</div>
<div>
<p className="text-text-muted">{t("evictions")}</p>
<p className="font-mono text-text-main">{cache.evictions ?? 0}</p>
{/* Savings */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Tokens Saved</p>
<p className="font-mono text-lg text-green-400">
{metrics.tokensSaved.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Est. Cost Saved</p>
<p className="font-mono text-lg text-green-400">
${metrics.estimatedCostSaved.toFixed(4)}
</p>
</div>
</div>
{/* By Provider */}
{Object.keys(metrics.byProvider).length > 0 && (
<div className="pt-3 border-t border-border/30">
<p className="text-xs font-medium text-text-muted mb-2">By Provider</p>
<div className="space-y-2">
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
const providerCacheRate =
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
return (
<div
key={provider}
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
>
<div className="flex items-center gap-3">
<span className="text-text-main capitalize w-24">{provider}</span>
<span className="text-text-muted">{stats.requests} reqs</span>
</div>
<div className="flex items-center gap-4 font-mono">
<span className="text-text-muted" title="Input tokens">
In: {stats.inputTokens.toLocaleString()}
</span>
<span className="text-green-400" title="Cached tokens (reads)">
Cached: {stats.cachedTokens.toLocaleString()}
</span>
<span className="text-blue-400" title="Cache creation tokens (writes)">
Write: {stats.cacheCreationTokens.toLocaleString()}
</span>
<span className="text-green-400 w-12 text-right">
{providerCacheRate.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-text-muted">{t("loadingCacheStats")}</p>
<p className="text-sm text-text-muted">Loading cache metrics...</p>
)}
</Card>
);

View File

@@ -19,7 +19,10 @@ const STRATEGIES = ROUTING_STRATEGIES.filter((strategy) =>
}));
export default function RoutingTab() {
const [settings, setSettings] = useState<any>({ fallbackStrategy: "fill-first" });
const [settings, setSettings] = useState<any>({
fallbackStrategy: "fill-first",
alwaysPreserveClientCache: "auto",
});
const [loading, setLoading] = useState(true);
const [aliases, setAliases] = useState([]);
const [newPattern, setNewPattern] = useState("");
@@ -218,6 +221,74 @@ export default function RoutingTab() {
{/* Fallback Chains */}
<FallbackChainsEditor />
{/* Client Cache Control */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
cached
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Client Cache Control</h3>
<p className="text-sm text-text-muted">
Configure how client-side cache_control headers are handled
</p>
</div>
</div>
<div className="space-y-3">
{[
{
value: "auto",
label: "Auto (Recommended)",
desc: "Preserve cache_control only for caching-aware clients (Claude Code) with deterministic routing",
},
{
value: "always",
label: "Always Preserve",
desc: "Always forward client cache_control headers to upstream providers",
},
{
value: "never",
label: "Never Preserve",
desc: "Always remove client cache_control headers, let OmniRoute manage caching",
},
].map((option) => (
<button
key={option.value}
onClick={() => updateSetting({ alwaysPreserveClientCache: option.value })}
disabled={loading}
className={`w-full flex flex-col items-start gap-1 p-3 rounded-lg border text-left transition-all ${
settings.alwaysPreserveClientCache === option.value
? "border-green-500/50 bg-green-500/5 ring-1 ring-green-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[16px] ${
settings.alwaysPreserveClientCache === option.value
? "text-green-400"
: "text-text-muted"
}`}
>
{settings.alwaysPreserveClientCache === option.value
? "check_circle"
: "radio_button_unchecked"}
</span>
<span
className={`text-sm font-medium ${settings.alwaysPreserveClientCache === option.value ? "text-green-400" : ""}`}
>
{option.label}
</span>
</div>
<p className="text-xs text-text-muted ml-7">{option.desc}</p>
</button>
))}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getCacheMetrics, resetCacheMetrics } from "@/lib/db/settings";
export async function GET() {
try {
const metrics = await getCacheMetrics();
return NextResponse.json(metrics);
} catch (error) {
console.error("Error getting cache metrics:", error);
return NextResponse.json({ error: "Failed to load cache metrics" }, { status: 500 });
}
}
export async function DELETE() {
try {
const metrics = await resetCacheMetrics();
return NextResponse.json(metrics);
} catch (error) {
console.error("Error resetting cache metrics:", error);
return NextResponse.json({ error: "Failed to reset cache metrics" }, { status: 500 });
}
}

View File

@@ -119,6 +119,12 @@ export async function PATCH(request) {
invalidateCallLogsMaxCache();
}
// Sync cache control settings to runtime cache
if ("alwaysPreserveClientCache" in body) {
const { invalidateCacheControlSettingsCache } = await import("@/lib/cacheControlSettings");
invalidateCacheControlSettingsCache();
}
const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings);
} catch (error) {

View File

@@ -0,0 +1,25 @@
/**
* Cache Control Settings
*
* Provides cached access to cache control settings for performance.
* Settings are fetched once and cached to avoid repeated DB hits.
*/
import { getSettings } from "./db/settings";
import type { CacheControlMode } from "@omniroute/open-sse/utils/cacheControlPolicy";
let cachedSettings: CacheControlMode | null = null;
export async function getCacheControlSettings(): Promise<CacheControlMode> {
if (cachedSettings !== null) {
return cachedSettings;
}
const settings = await getSettings();
cachedSettings = (settings.alwaysPreserveClientCache as CacheControlMode) || "auto";
return cachedSettings;
}
export function invalidateCacheControlSettingsCache() {
cachedSettings = null;
}

View File

@@ -46,6 +46,7 @@ export async function getSettings() {
stickyRoundRobinLimit: 3,
requireLogin: true,
hiddenSidebarItems: [],
alwaysPreserveClientCache: "auto",
};
for (const row of rows) {
const record = toRecord(row);
@@ -486,3 +487,56 @@ export async function setProxyConfig(config: Record<string, unknown>) {
backupDbFile("pre-write");
return current;
}
// ──────────────── Cache Control Metrics ────────────────
export async function getCacheMetrics() {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'cacheMetrics'")
.get() as { value?: string } | undefined;
if (!row || !row.value) {
return {
totalRequests: 0,
requestsWithCacheControl: 0,
totalInputTokens: 0,
totalCachedTokens: 0,
totalCacheCreationTokens: 0,
tokensSaved: 0,
estimatedCostSaved: 0,
byProvider: {},
byStrategy: {},
lastUpdated: new Date().toISOString(),
};
}
return JSON.parse(row.value);
}
export async function updateCacheMetrics(metrics: Record<string, unknown>) {
const db = getDbInstance();
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'cacheMetrics', ?)"
).run(JSON.stringify(metrics));
backupDbFile("pre-write");
return metrics;
}
export async function resetCacheMetrics() {
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = 'settings' AND key = 'cacheMetrics'").run();
backupDbFile("pre-write");
return {
totalRequests: 0,
requestsWithCacheControl: 0,
totalInputTokens: 0,
totalCachedTokens: 0,
totalCacheCreationTokens: 0,
tokensSaved: 0,
estimatedCostSaved: 0,
byProvider: {},
byStrategy: {},
lastUpdated: new Date().toISOString(),
};
}

View File

@@ -47,6 +47,8 @@ export const updateSettingsSchema = z.object({
cliCompatProviders: z.array(z.string().max(100)).optional(),
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
stripModelPrefix: z.boolean().optional(),
// Cache control preservation mode
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
// Custom CLI agent definitions for ACP
customAgents: z
.array(