mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/app/api/settings/cache-metrics/route.ts
Normal file
22
src/app/api/settings/cache-metrics/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
25
src/lib/cacheControlSettings.ts
Normal file
25
src/lib/cacheControlSettings.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user