From ae1a0f411b7f2e96c81faae7a018d90659c597a2 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Tue, 31 Mar 2026 00:53:18 +0700 Subject: [PATCH] feat(cache): fix cache page to display prompt cache metrics and trend data Closes #813 --- src/app/(dashboard)/dashboard/cache/page.tsx | 195 ++++++++++++++++++- src/app/api/cache/route.ts | 24 +-- src/i18n/messages/en.json | 15 +- src/lib/db/settings.ts | 59 +++++- 4 files changed, 270 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index eaa51e4ad6..34007450e2 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -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; + byStrategy: Record; + 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 (
{/* Header */} @@ -278,6 +314,153 @@ export default function CachePage() {
+ {/* Prompt Cache Stats */} + {pc && ( + +
+
+ +

{t("promptCache")}

+
+ +
+
+
+ {pc.requestsWithCacheControl.toLocaleString()} +
+
{t("cachedRequests")}
+
+
+
+ {promptCacheHitRate.toFixed(1)}% +
+
{t("cacheHitRate")}
+
+
+
+ {pc.totalCachedTokens.toLocaleString()} +
+
{t("cachedTokens")}
+
+
+
+ {pc.totalCacheCreationTokens.toLocaleString()} +
+
{t("cacheCreationTokens")}
+
+
+ + {providerEntries.length > 0 && ( +
+

{t("byProvider")}

+
+ + + + + + + + + + + + {providerEntries.map(([provider, data]) => ( + + + + + + + + ))} + +
{t("provider")}{t("requests")}{t("inputTokens")}{t("cachedTokensCol")}{t("cacheCreation")}
{provider} + {data.requests.toLocaleString()} + + {data.inputTokens.toLocaleString()} + + {data.cachedTokens.toLocaleString()} + + {data.cacheCreationTokens.toLocaleString()} +
+
+
+ )} +
+
+ )} + + {/* Cache Trend (24h) */} + {trend.length > 0 && ( + +
+
+ +

{t("trend24h")}

+
+
+ {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 ( +
+
+ {hour}: {point.requests} {t("requests").toLowerCase()},{" "} + {point.cachedRequests} {t("cached").toLowerCase()} +
+
+
+
+
+ + {hour.split(":")[0]} + +
+ ); + })} +
+
+
+
+ {t("total")} +
+
+
+ {t("cached")} +
+
+
+ + )} + {/* Cache behavior */}
diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts index ebb02dc2f4..d1bca53891 100644 --- a/src/app/api/cache/route.ts +++ b/src/app/api/cache/route.ts @@ -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= — invalidate all entries for a specific model - * ?signature= — invalidate a single entry by its SHA-256 signature - * ?staleMs= — 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); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3b1dc587c8..abca0616d0 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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" } } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 00224a353a..333d71379f 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -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) { return getCacheMetrics(); } +export interface CacheTrendPoint { + timestamp: string; + requests: number; + cachedRequests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; +} + +export async function getCacheTrend(hours = 24): Promise { + 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