feat(cache): fix cache page to display prompt cache metrics and trend data

Closes #813
This commit is contained in:
oyi77
2026-03-31 00:53:18 +07:00
parent 6ad0910790
commit ae1a0f411b
4 changed files with 270 additions and 23 deletions

View File

@@ -16,13 +16,44 @@ interface SemanticCacheStats {
tokensSaved: number;
}
interface PromptCacheProviderStats {
requests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
interface PromptCacheMetrics {
totalRequests: number;
requestsWithCacheControl: number;
totalInputTokens: number;
totalCachedTokens: number;
totalCacheCreationTokens: number;
tokensSaved: number;
estimatedCostSaved: number;
byProvider: Record<string, PromptCacheProviderStats>;
byStrategy: Record<string, PromptCacheProviderStats>;
lastUpdated: string;
}
interface IdempotencyStats {
activeKeys: number;
windowMs: number;
}
interface CacheTrendPoint {
timestamp: string;
requests: number;
cachedRequests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
interface CacheStats {
semanticCache: SemanticCacheStats;
promptCache: PromptCacheMetrics | null;
trend: CacheTrendPoint[];
idempotency: IdempotencyStats;
}
@@ -136,27 +167,32 @@ export default function CachePage() {
const res = await fetch("/api/cache", { method: "DELETE" });
if (res.ok) {
const data = await res.json();
notify.add({
type: "success",
message: t("clearSuccess", { count: data.expiredRemoved ?? 0 }),
});
notify.success(t("clearSuccess", { count: data.expiredRemoved ?? 0 }));
await fetchStats();
} else {
notify.add({ type: "error", message: t("clearError") });
notify.error(t("clearError"));
}
} catch (error) {
console.error("[CachePage] Failed to clear cache:", error);
notify.add({ type: "error", message: t("clearError") });
notify.error(t("clearError"));
} finally {
setClearing(false);
}
};
const sc = stats?.semanticCache;
const pc = stats?.promptCache;
const trend = stats?.trend ?? [];
const idp = stats?.idempotency;
const hitRate = sc ? parseFloat(sc.hitRate) : 0;
const totalRequests = sc ? sc.hits + sc.misses : 0;
const promptCacheHitRate =
pc && pc.totalRequests > 0 ? (pc.requestsWithCacheControl / pc.totalRequests) * 100 : 0;
const providerEntries = pc ? Object.entries(pc.byProvider) : [];
const maxTrendRequests = Math.max(1, ...trend.map((p) => p.requests));
return (
<div className="flex flex-col gap-6">
{/* Header */}
@@ -278,6 +314,153 @@ export default function CachePage() {
</div>
</Card>
{/* Prompt Cache Stats */}
{pc && (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
bolt
</span>
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{pc.requestsWithCacheControl.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cachedRequests")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-green-500">
{promptCacheHitRate.toFixed(1)}%
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-blue-400">
{pc.totalCachedTokens.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cachedTokens")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-purple-400">
{pc.totalCacheCreationTokens.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cacheCreationTokens")}</div>
</div>
</div>
{providerEntries.length > 0 && (
<div className="pt-3 border-t border-border/30">
<h3 className="text-xs font-medium text-text-muted mb-3">{t("byProvider")}</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border/30">
<th className="pb-2 pr-4">{t("provider")}</th>
<th className="pb-2 pr-4">{t("requests")}</th>
<th className="pb-2 pr-4">{t("inputTokens")}</th>
<th className="pb-2 pr-4">{t("cachedTokensCol")}</th>
<th className="pb-2">{t("cacheCreation")}</th>
</tr>
</thead>
<tbody>
{providerEntries.map(([provider, data]) => (
<tr key={provider} className="border-b border-border/20">
<td className="py-2 pr-4 font-medium">{provider}</td>
<td className="py-2 pr-4 tabular-nums">
{data.requests.toLocaleString()}
</td>
<td className="py-2 pr-4 tabular-nums">
{data.inputTokens.toLocaleString()}
</td>
<td className="py-2 pr-4 tabular-nums text-green-500">
{data.cachedTokens.toLocaleString()}
</td>
<td className="py-2 tabular-nums text-purple-400">
{data.cacheCreationTokens.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Card>
)}
{/* Cache Trend (24h) */}
{trend.length > 0 && (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
timeline
</span>
<h2 className="font-medium text-sm">{t("trend24h")}</h2>
</div>
<div className="flex items-end gap-1 h-32">
{trend.map((point) => {
const height = Math.max(4, (point.requests / maxTrendRequests) * 100);
const cachedHeight =
point.requests > 0
? Math.max(2, (point.cachedRequests / point.requests) * height)
: 0;
const hour = new Date(point.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return (
<div
key={point.timestamp}
className="flex-1 flex flex-col items-center gap-1 group relative"
>
<div className="absolute bottom-full mb-1 hidden group-hover:block bg-surface-raised border border-border rounded px-2 py-1 text-xs whitespace-nowrap z-10">
{hour}: {point.requests} {t("requests").toLowerCase()},{" "}
{point.cachedRequests} {t("cached").toLowerCase()}
</div>
<div className="w-full flex flex-col justify-end h-full gap-px">
<div
className="w-full bg-green-500/30 rounded-t"
style={{ height: `${cachedHeight}%` }}
/>
<div
className="w-full bg-text-muted/20 rounded-t"
style={{ height: `${height - cachedHeight}%` }}
/>
</div>
<span className="text-[10px] text-text-muted truncate w-full text-center">
{hour.split(":")[0]}
</span>
</div>
);
})}
</div>
<div className="flex items-center gap-4 text-xs text-text-muted">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-text-muted/20" />
<span>{t("total")}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-green-500/30" />
<span>{t("cached")}</span>
</div>
</div>
</div>
</Card>
)}
{/* Cache behavior */}
<Card>
<div className="p-5 flex flex-col gap-3">

View File

@@ -8,21 +8,26 @@ import {
invalidateStale,
} from "@/lib/semanticCache";
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* GET /api/cache — Cache statistics
*/
export async function GET() {
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const trendHours = parseInt(searchParams.get("trendHours") || "24", 10);
const cacheStats = getCacheStats();
const idempotencyStats = getIdempotencyStats();
const promptCacheMetrics = await getCacheMetrics();
const trend = await getCacheTrend(trendHours);
return NextResponse.json({
semanticCache: cacheStats,
promptCache: promptCacheMetrics,
trend,
idempotency: idempotencyStats,
});
} catch (error) {
@@ -30,17 +35,6 @@ export async function GET() {
}
}
/**
* DELETE /api/cache — Clear all caches or targeted invalidation.
*
* Exactly one optional query parameter may be provided:
* ?model=<name> — invalidate all entries for a specific model
* ?signature=<hex> — invalidate a single entry by its SHA-256 signature
* ?staleMs=<number> — invalidate entries older than N milliseconds
* (no params) — clear all cache entries
*
* Providing more than one parameter returns 400 Bad Request.
*/
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);

View File

@@ -2916,6 +2916,19 @@
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running."
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"promptCache": "Prompt Cache (Provider-Side)",
"cachedRequests": "Cached Requests",
"cacheHitRate": "Cache Hit Rate",
"cachedTokens": "Cached Tokens",
"cacheCreationTokens": "Cache Creation Tokens",
"byProvider": "Breakdown by Provider",
"provider": "Provider",
"requests": "Requests",
"inputTokens": "Input Tokens",
"cachedTokensCol": "Cached",
"cacheCreation": "Creation",
"trend24h": "Cache Trend (24h)",
"cached": "Cached"
}
}

View File

@@ -577,9 +577,14 @@ export async function getCacheMetrics() {
cacheCreationTokens: number | null;
}>;
// Calculate tokens saved (cached tokens are reused, not charged at full price)
const tokensSaved = totalsRow?.totalCachedTokens || 0;
const AVG_INPUT_PRICE_PER_MILLION = 3;
const CACHE_DISCOUNT = 0.9;
const estimatedCostSaved =
Math.round((tokensSaved / 1_000_000) * AVG_INPUT_PRICE_PER_MILLION * CACHE_DISCOUNT * 100) /
100;
// Build byProvider object
const byProvider: Record<
string,
@@ -653,6 +658,58 @@ export async function updateCacheMetrics(_metrics: Record<string, unknown>) {
return getCacheMetrics();
}
export interface CacheTrendPoint {
timestamp: string;
requests: number;
cachedRequests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
export async function getCacheTrend(hours = 24): Promise<CacheTrendPoint[]> {
const db = getDbInstance();
try {
const rows = db
.prepare(
`
SELECT
strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour,
COUNT(*) as requests,
SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests,
SUM(tokens_input) as inputTokens,
SUM(tokens_cache_read) as cachedTokens,
SUM(tokens_cache_creation) as cacheCreationTokens
FROM usage_history
WHERE timestamp >= datetime('now', ?)
GROUP BY hour
ORDER BY hour ASC
`
)
.all(`-${hours} hours`) as Array<{
hour: string;
requests: number;
cachedRequests: number;
inputTokens: number | null;
cachedTokens: number | null;
cacheCreationTokens: number | null;
}>;
return rows.map((r) => ({
timestamp: r.hour,
requests: r.requests,
cachedRequests: r.cachedRequests,
inputTokens: r.inputTokens || 0,
cachedTokens: r.cachedTokens || 0,
cacheCreationTokens: r.cacheCreationTokens || 0,
}));
} catch (error) {
console.error("Failed to fetch cache trend:", error);
return [];
}
}
export async function resetCacheMetrics() {
// No-op: cannot delete historical usage data
// Cache metrics are computed from usage_history, so they reflect actual request history