mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
fix semantic cache toggle and redesign cache management (#1135)
Integrated into release/v3.6.2
This commit is contained in:
@@ -51,12 +51,13 @@ import {
|
||||
getModelPreserveOpenAIDeveloperRole,
|
||||
getModelUpstreamExtraHeaders,
|
||||
getUpstreamProxyConfig,
|
||||
getCachedSettings,
|
||||
} from "@/lib/localDb";
|
||||
import { getExecutor } from "../executors/index.ts";
|
||||
import { getCacheControlSettings } from "@/lib/cacheControlSettings";
|
||||
import {
|
||||
shouldPreserveCacheControl,
|
||||
providerSupportsCaching,
|
||||
type CacheControlMode,
|
||||
} from "../utils/cacheControlPolicy.ts";
|
||||
import { getCacheMetrics } from "@/lib/db/settings.ts";
|
||||
|
||||
@@ -810,9 +811,16 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
const stream = resolveStreamFlag(body?.stream, acceptHeader);
|
||||
const runtimeSettings = await getCachedSettings().catch(() => ({}) as Record<string, unknown>);
|
||||
const semanticCacheEnabled = runtimeSettings.semanticCacheEnabled !== false;
|
||||
const cacheControlMode =
|
||||
runtimeSettings.alwaysPreserveClientCache === "always" ||
|
||||
runtimeSettings.alwaysPreserveClientCache === "never"
|
||||
? (runtimeSettings.alwaysPreserveClientCache as CacheControlMode)
|
||||
: "auto";
|
||||
|
||||
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
if (semanticCacheEnabled && isCacheable(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
|
||||
const cached = getCachedResponse(signature);
|
||||
if (cached) {
|
||||
@@ -948,8 +956,6 @@ export async function handleChatCore({
|
||||
let ccSessionId: string | null = null;
|
||||
|
||||
// Determine if we should preserve client-side cache_control headers
|
||||
// Fetch settings from DB to get user preference
|
||||
const cacheControlMode = await getCacheControlSettings().catch(() => "auto" as const);
|
||||
const preserveCacheControl = shouldPreserveCacheControl({
|
||||
userAgent,
|
||||
isCombo,
|
||||
@@ -2269,7 +2275,7 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
if (semanticCacheEnabled && isCacheable(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
|
||||
const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0;
|
||||
setCachedResponse(signature, model, translatedResponse, tokensSaved);
|
||||
|
||||
@@ -884,8 +884,10 @@ export const cacheStatsOutput = z.object({
|
||||
.object({
|
||||
totalRequests: z.number(),
|
||||
requestsWithCacheControl: z.number(),
|
||||
totalInputTokens: z.number(),
|
||||
totalCachedTokens: z.number(),
|
||||
totalCacheCreationTokens: z.number(),
|
||||
tokensSaved: z.number(),
|
||||
estimatedCostSaved: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
@@ -893,6 +895,11 @@ export const cacheStatsOutput = z.object({
|
||||
activeKeys: z.number(),
|
||||
windowMs: z.number(),
|
||||
}),
|
||||
config: z
|
||||
.object({
|
||||
semanticCacheEnabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const cacheStatsTool: McpToolDefinition<typeof cacheStatsInput, typeof cacheStatsOutput> = {
|
||||
|
||||
210
src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx
vendored
Normal file
210
src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import React from "react";
|
||||
import CachePage from "../page";
|
||||
|
||||
const notifications = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace?: string) => {
|
||||
const getMessage = (key: string) => {
|
||||
const fullKey = namespace ? `${namespace}.${key}` : key;
|
||||
const messages: Record<string, string> = {
|
||||
"cache.title": "Cache Management",
|
||||
"cache.description":
|
||||
"Monitor provider prompt cache efficiency and local semantic response reuse.",
|
||||
"cache.refresh": "Refresh",
|
||||
"cache.promptCache": "Prompt Cache (Provider-Side)",
|
||||
"cache.promptCacheSectionDesc": "Prompt cache section",
|
||||
"cache.lastUpdated": "Last updated",
|
||||
"cache.withCacheControl": "With Cache Control",
|
||||
"cache.cacheRateDesc": "of total requests",
|
||||
"cache.cacheReuseRatio": "Cache Reuse Ratio",
|
||||
"cache.cacheReuseRatioDesc": "Cache read tokens / Total input tokens",
|
||||
"cache.cachedTokens": "Cache Read Tokens",
|
||||
"cache.cachedTokensRead": "Read from cache",
|
||||
"cache.estCostSaved": "Est. Cost Saved",
|
||||
"cache.cacheCreationTokens": "Cache Write Tokens",
|
||||
"cache.cacheRate": "Cache Rate",
|
||||
"cache.requests": "Requests",
|
||||
"cache.inputTokens": "Input Tokens",
|
||||
"cache.tokensSaved": "Tokens Saved",
|
||||
"cache.trend24h": "Cache Trend (24h)",
|
||||
"cache.cached": "Cached",
|
||||
"cache.byProvider": "Breakdown by Provider",
|
||||
"cache.providerCacheRateDesc": "Provider cache rate description",
|
||||
"cache.cachedTokensCol": "Cache Read",
|
||||
"cache.cacheCreation": "Cache Write",
|
||||
"cache.cacheCreationWrite": "Written to cache",
|
||||
"cache.inputTokens": "Total Input Tokens",
|
||||
"cache.semanticCache": "Semantic Cache",
|
||||
"cache.semanticCacheSectionDesc": "Semantic cache section",
|
||||
"cache.semanticCacheDisabledDesc": "Semantic cache is disabled.",
|
||||
"cache.memoryEntries": "Memory Entries",
|
||||
"cache.memoryEntriesSub": "In-memory LRU",
|
||||
"cache.dbEntries": "DB Entries",
|
||||
"cache.dbEntriesSub": "Persisted (SQLite)",
|
||||
"cache.cacheHits": "Cache Hits",
|
||||
"cache.cacheHitsSub": "of {total} total",
|
||||
"cache.tokensSavedSub": "Estimated from hits",
|
||||
"cache.performance": "Cache Performance",
|
||||
"cache.autoRefresh": "Auto-refreshes every {seconds}s",
|
||||
"cache.hitRate": "Hit Rate",
|
||||
"cache.hits": "Hits",
|
||||
"cache.misses": "Misses",
|
||||
"cache.total": "Total",
|
||||
"cache.behavior": "Cache Behavior",
|
||||
"cache.behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
|
||||
"cache.behaviorTwoTier": "Two-tier storage: in-memory LRU + SQLite.",
|
||||
"cache.behaviorTtl": "TTL via {envVar}.",
|
||||
"cache.idempotency": "Idempotency Layer",
|
||||
"cache.activeDedupKeys": "Active Dedup Keys",
|
||||
"cache.dedupWindow": "Dedup Window",
|
||||
"cache.entries": "Entries",
|
||||
"cache.semanticEntriesDesc": "Semantic entries only.",
|
||||
"cache.searchEntries": "Search entries...",
|
||||
"cache.search": "Search",
|
||||
"cache.loading": "Loading...",
|
||||
"cache.noEntries": "No cache entries found",
|
||||
"cache.clearAll": "Clear Semantic Cache",
|
||||
"settings.enabled": "Enabled",
|
||||
disabled: "Disabled",
|
||||
};
|
||||
|
||||
return messages[fullKey] ?? key;
|
||||
};
|
||||
|
||||
const translate = (key: string, values?: Record<string, unknown>) => {
|
||||
let message = getMessage(key);
|
||||
if (values) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
message = message.replace(`{${name}}`, String(value));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
translate.rich = (key: string, values?: Record<string, () => React.ReactNode>) => {
|
||||
if (key === "behaviorBypass") {
|
||||
return <>Bypass with header {values?.header?.()}.</>;
|
||||
}
|
||||
if (key === "behaviorTtl") {
|
||||
return <>TTL via {values?.envVar?.()}.</>;
|
||||
}
|
||||
return translate(key);
|
||||
};
|
||||
|
||||
return translate;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/notificationStore", () => ({
|
||||
useNotificationStore: () => notifications,
|
||||
}));
|
||||
|
||||
describe("CachePage", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
notifications.success.mockReset();
|
||||
notifications.error.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("switches between prompt and semantic cache views", async () => {
|
||||
fetchMock.mockImplementation(async (input: string) => {
|
||||
if (input.startsWith("/api/cache/entries")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entries: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
semanticCache: {
|
||||
memoryEntries: 0,
|
||||
dbEntries: 0,
|
||||
hits: 0,
|
||||
misses: 3,
|
||||
hitRate: "0.0",
|
||||
tokensSaved: 0,
|
||||
},
|
||||
promptCache: {
|
||||
totalRequests: 10,
|
||||
requestsWithCacheControl: 6,
|
||||
totalInputTokens: 1000,
|
||||
totalCachedTokens: 550,
|
||||
totalCacheCreationTokens: 180,
|
||||
tokensSaved: 550,
|
||||
estimatedCostSaved: 0.12,
|
||||
byProvider: {
|
||||
claude: {
|
||||
requests: 6,
|
||||
totalRequests: 10,
|
||||
cachedRequests: 6,
|
||||
inputTokens: 1000,
|
||||
cachedTokens: 550,
|
||||
cacheCreationTokens: 180,
|
||||
},
|
||||
},
|
||||
byStrategy: {},
|
||||
lastUpdated: "2026-04-11T05:00:00.000Z",
|
||||
},
|
||||
trend: [
|
||||
{
|
||||
timestamp: "2026-04-11T05:00:00.000Z",
|
||||
requests: 10,
|
||||
cachedRequests: 6,
|
||||
inputTokens: 1000,
|
||||
cachedTokens: 550,
|
||||
cacheCreationTokens: 180,
|
||||
},
|
||||
],
|
||||
idempotency: {
|
||||
activeKeys: 2,
|
||||
windowMs: 5000,
|
||||
},
|
||||
config: {
|
||||
semanticCacheEnabled: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
render(<CachePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Prompt Cache (Provider-Side)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Breakdown by Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("60.0%").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Semantic cache is disabled.")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Semantic Cache" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Semantic cache is disabled.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Entries")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Dedup Keys")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,12 @@ export default function CacheEntriesTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEntries = useCallback(
|
||||
async (page = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(pagination.limit) });
|
||||
if (search) params.set("search", search);
|
||||
@@ -46,14 +48,18 @@ export default function CacheEntriesTab() {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries);
|
||||
setPagination(data.pagination);
|
||||
} else {
|
||||
setEntries([]);
|
||||
setError(t("entriesLoadError"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
setEntries([]);
|
||||
setError(t("entriesLoadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[search, pagination.limit]
|
||||
[pagination.limit, search, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,6 +100,13 @@ export default function CacheEntriesTab() {
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-text-muted">{t("loading")}</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3">
|
||||
<div className="text-sm text-red-300">{error}</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchEntries(pagination.page)}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8">{t("noEntries")}</div>
|
||||
) : (
|
||||
|
||||
1091
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
1091
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
File diff suppressed because it is too large
Load Diff
11
src/app/api/cache/route.ts
vendored
11
src/app/api/cache/route.ts
vendored
@@ -2,13 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getCacheStats,
|
||||
clearCache,
|
||||
cleanExpiredEntries,
|
||||
invalidateByModel,
|
||||
invalidateBySignature,
|
||||
invalidateStale,
|
||||
} from "@/lib/semanticCache";
|
||||
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
|
||||
import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings";
|
||||
import { getCachedSettings } from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
@@ -29,12 +29,16 @@ export async function GET(req: NextRequest) {
|
||||
const idempotencyStats = await getIdempotencyStats();
|
||||
const promptCacheMetrics = await getCacheMetrics();
|
||||
const trend = await getCacheTrend(trendHours);
|
||||
const settings = await getCachedSettings().catch(() => ({}));
|
||||
|
||||
return NextResponse.json({
|
||||
semanticCache: cacheStats,
|
||||
promptCache: promptCacheMetrics,
|
||||
trend,
|
||||
idempotency: idempotencyStats,
|
||||
config: {
|
||||
semanticCacheEnabled: settings.semanticCacheEnabled !== false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
|
||||
@@ -87,9 +91,8 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Full clear
|
||||
clearCache();
|
||||
const expiredRemoved = cleanExpiredEntries();
|
||||
return NextResponse.json({ ok: true, expiredRemoved, scope: "all" });
|
||||
const cleared = clearCache();
|
||||
return NextResponse.json({ ok: true, cleared, scope: "all" });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3114,9 +3114,9 @@
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache Management",
|
||||
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
|
||||
"description": "Monitor provider prompt cache efficiency and local semantic response reuse.",
|
||||
"refresh": "Refresh",
|
||||
"clearAll": "Clear All",
|
||||
"clearAll": "Clear Semantic Cache",
|
||||
"memoryEntries": "Memory Entries",
|
||||
"memoryEntriesSub": "In-memory LRU",
|
||||
"dbEntries": "DB Entries",
|
||||
@@ -3139,22 +3139,34 @@
|
||||
"idempotency": "Idempotency Layer",
|
||||
"activeDedupKeys": "Active Dedup Keys",
|
||||
"dedupWindow": "Dedup Window",
|
||||
"clearSuccess": "Cache cleared. {count} expired entries removed.",
|
||||
"clearSuccess": "Semantic cache cleared. {count} entries removed.",
|
||||
"clearError": "Failed to clear cache.",
|
||||
"unavailable": "Cache unavailable",
|
||||
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
|
||||
"promptCache": "Prompt Cache (Provider-Side)",
|
||||
"semanticCache": "Semantic Cache",
|
||||
"promptCacheSectionDesc": "Shows provider-side prompt caching activity from usage history so you can see where cache control is active and how much input reuse you are getting.",
|
||||
"promptTrendDesc": "Hourly request volume, cache coverage, and cache read volume across the last 24 hours.",
|
||||
"cachedRequests": "Cached Requests",
|
||||
"cachedRequests24h": "Cached Requests (24h)",
|
||||
"cacheHitRate": "Cache Hit Rate",
|
||||
"cachedTokens": "Cached Tokens",
|
||||
"cacheCreationTokens": "Cache Creation Tokens",
|
||||
"cacheRate": "Cache Rate",
|
||||
"cacheRateDesc": "of total requests",
|
||||
"cachedTokens": "Cache Read Tokens",
|
||||
"cacheCreationTokens": "Cache Write Tokens",
|
||||
"cacheMetrics": "Prompt Cache Metrics",
|
||||
"withCacheControl": "With Cache Control",
|
||||
"cachedTokensRead": "Cached Tokens (Read)",
|
||||
"cacheCreationWrite": "Cache Creation (Write)",
|
||||
"cachedTokensRead": "Read from cache",
|
||||
"cacheCreationWrite": "Written to cache",
|
||||
"cacheReuseRatio": "Cache Reuse Ratio",
|
||||
"cacheReuseRatioDesc": "Cached tokens / Total input tokens",
|
||||
"cacheReuseRatioDesc": "Cache read tokens / Total input tokens",
|
||||
"estCostSaved": "Est. Cost Saved",
|
||||
"lastUpdated": "Last updated",
|
||||
"hoursTracked": "hours tracked",
|
||||
"busiestHour": "Busiest Hour",
|
||||
"peakCacheRate": "Peak Cache Rate",
|
||||
"trendHour": "Hour",
|
||||
"activityVolume": "Activity",
|
||||
"requestsShort": "reqs",
|
||||
"inputShort": "In",
|
||||
"cachedShort": "Cached",
|
||||
@@ -3162,20 +3174,27 @@
|
||||
"resetting": "Resetting...",
|
||||
"resetMetrics": "Reset Metrics",
|
||||
"byProvider": "Breakdown by Provider",
|
||||
"providerCacheRateDesc": "Each provider exposes total input tokens, cache read tokens, and cache write tokens so you can verify the reuse ratio against raw totals.",
|
||||
"provider": "Provider",
|
||||
"requests": "Requests",
|
||||
"inputTokens": "Input Tokens",
|
||||
"cachedTokensCol": "Cached",
|
||||
"cacheCreation": "Creation",
|
||||
"inputTokens": "Total Input Tokens",
|
||||
"cachedTokensCol": "Cache Read",
|
||||
"cacheCreation": "Cache Write",
|
||||
"trend24h": "Cache Trend (24h)",
|
||||
"peakCached": "Peak cached",
|
||||
"cached": "Cached",
|
||||
"overview": "Overview",
|
||||
"entries": "Entries",
|
||||
"semanticCacheSectionDesc": "OmniRoute's own deterministic response cache. When enabled, repeated non-streaming temperature=0 requests can be served locally without hitting the upstream provider.",
|
||||
"semanticCacheDisabledDesc": "Semantic cache is disabled. OmniRoute will skip local response reuse until you turn it back on in Settings.",
|
||||
"semanticEntriesDesc": "Persisted semantic cache records currently stored in SQLite. Provider-side prompt cache activity is not listed here.",
|
||||
"searchEntries": "Search entries...",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"entriesLoadError": "Failed to load semantic cache entries.",
|
||||
"noEntries": "No cache entries found",
|
||||
"noPromptCacheData": "No provider-side prompt cache activity has been recorded yet.",
|
||||
"noTrendData": "No prompt cache activity has been recorded over the last 24 hours.",
|
||||
"signature": "Signature",
|
||||
"model": "Model",
|
||||
"created": "Created",
|
||||
|
||||
@@ -3054,9 +3054,9 @@
|
||||
},
|
||||
"cache": {
|
||||
"title": "缓存管理",
|
||||
"description": "监控和管理语义响应缓存、命中率及 Tokens 节省情况。",
|
||||
"description": "监控提供商侧 Prompt Cache 的效率,以及本地 Semantic Cache 的响应复用情况。",
|
||||
"refresh": "刷新",
|
||||
"clearAll": "清空全部",
|
||||
"clearAll": "清空语义缓存",
|
||||
"memoryEntries": "内存条目",
|
||||
"memoryEntriesSub": "内存 LRU",
|
||||
"dbEntries": "数据库条目",
|
||||
@@ -3079,22 +3079,34 @@
|
||||
"idempotency": "幂等层",
|
||||
"activeDedupKeys": "活跃去重键",
|
||||
"dedupWindow": "去重窗口",
|
||||
"clearSuccess": "缓存已清除。已删除 {count} 条过期条目。",
|
||||
"clearSuccess": "语义缓存已清空,已删除 {count} 条记录。",
|
||||
"clearError": "清除缓存失败。",
|
||||
"unavailable": "缓存不可用",
|
||||
"unavailableDesc": "无法获取缓存统计信息。请确保服务器正在运行。",
|
||||
"promptCache": "Prompt 缓存(提供商侧)",
|
||||
"semanticCache": "语义缓存",
|
||||
"promptCacheSectionDesc": "基于 usage history 展示提供商侧 prompt cache 的活跃度,让你区分哪些请求真的启用了 cache control,以及实际复用了多少输入。",
|
||||
"promptTrendDesc": "按小时展示最近 24 小时的请求量、缓存覆盖率,以及 cache read token 的变化。",
|
||||
"cachedRequests": "缓存请求数",
|
||||
"cachedRequests24h": "24 小时缓存请求数",
|
||||
"cacheHitRate": "缓存命中率",
|
||||
"cachedTokens": "缓存 Tokens",
|
||||
"cacheCreationTokens": "缓存创建 Tokens",
|
||||
"cacheRate": "缓存率",
|
||||
"cacheRateDesc": "占总请求数",
|
||||
"cachedTokens": "Cache Read Tokens",
|
||||
"cacheCreationTokens": "Cache Write Tokens",
|
||||
"cacheMetrics": "Prompt 缓存指标",
|
||||
"withCacheControl": "含缓存控制",
|
||||
"cachedTokensRead": "缓存 Tokens(读取)",
|
||||
"cacheCreationWrite": "缓存创建(写入)",
|
||||
"cachedTokensRead": "Read from cache",
|
||||
"cacheCreationWrite": "Written to cache",
|
||||
"cacheReuseRatio": "缓存复用率",
|
||||
"cacheReuseRatioDesc": "缓存 Tokens / 输入 Tokens 总量",
|
||||
"cacheReuseRatioDesc": "Cache read tokens / 输入 Tokens 总量",
|
||||
"estCostSaved": "预估节省费用",
|
||||
"lastUpdated": "上次更新",
|
||||
"hoursTracked": "个小时",
|
||||
"busiestHour": "最繁忙时段",
|
||||
"peakCacheRate": "最高缓存率",
|
||||
"trendHour": "小时",
|
||||
"activityVolume": "活动量",
|
||||
"requestsShort": "请求",
|
||||
"inputShort": "输入",
|
||||
"cachedShort": "已缓存",
|
||||
@@ -3102,20 +3114,27 @@
|
||||
"resetting": "正在重置...",
|
||||
"resetMetrics": "重置指标",
|
||||
"byProvider": "按提供商分类",
|
||||
"providerCacheRateDesc": "每个提供商都会直接展示总输入 Tokens、cache read tokens 和 cache write tokens,方便你对照原始数据判断 ratio 是否可靠。",
|
||||
"provider": "提供商",
|
||||
"requests": "请求数",
|
||||
"inputTokens": "输入 Tokens",
|
||||
"cachedTokensCol": "已缓存",
|
||||
"cacheCreation": "创建",
|
||||
"inputTokens": "输入 Tokens 总计",
|
||||
"cachedTokensCol": "Cache Read",
|
||||
"cacheCreation": "Cache Write",
|
||||
"trend24h": "缓存趋势(24 小时)",
|
||||
"peakCached": "峰值缓存量",
|
||||
"cached": "已缓存",
|
||||
"overview": "概览",
|
||||
"entries": "条目",
|
||||
"semanticCacheSectionDesc": "OmniRoute 自己维护的确定性响应缓存。开启后,重复的非流式、temperature=0 请求可以直接在本地命中,不再访问上游 provider。",
|
||||
"semanticCacheDisabledDesc": "Semantic Cache 当前已禁用。重新在设置中开启之前,OmniRoute 不会再做本地响应复用。",
|
||||
"semanticEntriesDesc": "这里展示的是保存在 SQLite 里的 semantic cache 记录,不包含 provider-side prompt cache 的活动。",
|
||||
"searchEntries": "搜索条目...",
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"entriesLoadError": "加载语义缓存条目失败。",
|
||||
"noEntries": "未找到缓存条目",
|
||||
"noPromptCacheData": "暂时还没有记录到 provider-side prompt cache 活动。",
|
||||
"noTrendData": "最近 24 小时还没有记录到 prompt cache 活动。",
|
||||
"signature": "签名",
|
||||
"model": "模型",
|
||||
"created": "创建时间",
|
||||
|
||||
@@ -582,19 +582,21 @@ export async function getCacheMetrics() {
|
||||
`
|
||||
SELECT
|
||||
provider,
|
||||
COUNT(*) as requests,
|
||||
SUM(tokens_input) as inputTokens,
|
||||
COUNT(*) as totalRequests,
|
||||
SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests,
|
||||
SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN tokens_input ELSE 0 END) as inputTokens,
|
||||
SUM(tokens_cache_read) as cachedTokens,
|
||||
SUM(tokens_cache_creation) as cacheCreationTokens
|
||||
FROM usage_history
|
||||
WHERE (tokens_cache_read > 0 OR tokens_cache_creation > 0)
|
||||
AND provider IS NOT NULL
|
||||
WHERE provider IS NOT NULL
|
||||
GROUP BY provider
|
||||
HAVING cachedRequests > 0
|
||||
`
|
||||
)
|
||||
.all() as Array<{
|
||||
provider: string;
|
||||
requests: number;
|
||||
totalRequests: number;
|
||||
cachedRequests: number;
|
||||
inputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
cacheCreationTokens: number | null;
|
||||
@@ -638,6 +640,8 @@ export async function getCacheMetrics() {
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
totalRequests: number;
|
||||
cachedRequests: number;
|
||||
inputTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
@@ -645,7 +649,9 @@ export async function getCacheMetrics() {
|
||||
> = {};
|
||||
for (const row of byProviderRows) {
|
||||
byProvider[row.provider] = {
|
||||
requests: row.requests,
|
||||
requests: row.cachedRequests,
|
||||
totalRequests: row.totalRequests,
|
||||
cachedRequests: row.cachedRequests,
|
||||
inputTokens: row.inputTokens || 0,
|
||||
cachedTokens: row.cachedTokens || 0,
|
||||
cacheCreationTokens: row.cacheCreationTokens || 0,
|
||||
|
||||
@@ -336,15 +336,18 @@ export function cleanOldMetrics(retentionDays = 90): number {
|
||||
/**
|
||||
* Clear all cache entries.
|
||||
*/
|
||||
export function clearCache() {
|
||||
export function clearCache(): number {
|
||||
getMemoryCache().clear();
|
||||
let removed = 0;
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM semantic_cache").run();
|
||||
const result = db.prepare("DELETE FROM semantic_cache").run();
|
||||
removed = result.changes || 0;
|
||||
db.prepare("UPDATE cache_metrics SET value = 0").run();
|
||||
} catch {
|
||||
// DB not available
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
|
||||
@@ -123,6 +123,8 @@ describe("Cache Metrics Database", () => {
|
||||
// Check provider breakdown
|
||||
assert.ok(metrics.byProvider["test-provider"]);
|
||||
assert.ok(metrics.byProvider["test-provider"].requests >= 2);
|
||||
assert.ok(metrics.byProvider["test-provider"].totalRequests >= 2);
|
||||
assert.ok(metrics.byProvider["test-provider"].cachedRequests >= 2);
|
||||
assert.ok(metrics.byProvider["test-provider"].inputTokens >= 1500);
|
||||
assert.ok(metrics.byProvider["test-provider"].cachedTokens >= 600);
|
||||
assert.ok(metrics.byProvider["test-provider"].cacheCreationTokens >= 300);
|
||||
|
||||
@@ -869,7 +869,9 @@ test("chatCore refreshes GitHub credentials after 401 and retries with the refre
|
||||
});
|
||||
|
||||
const payload = await result.response.json();
|
||||
const providerCalls = calls.filter((entry) => entry.url.startsWith("https://api.githubcopilot.com/"));
|
||||
const providerCalls = calls.filter((entry) =>
|
||||
entry.url.startsWith("https://api.githubcopilot.com/")
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(providerCalls.length, 2);
|
||||
@@ -1096,6 +1098,49 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests"
|
||||
assert.equal(payload.choices[0].message.content, "cached-once");
|
||||
});
|
||||
|
||||
test("chatCore skips semantic cache when disabled in settings", async () => {
|
||||
await settingsDb.updateSettings({ semanticCacheEnabled: false });
|
||||
|
||||
let upstreamHits = 0;
|
||||
const sharedBody = {
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
temperature: 0,
|
||||
messages: [{ role: "user", content: "do not reuse this response locally" }],
|
||||
};
|
||||
|
||||
const first = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
body: sharedBody,
|
||||
responseFormat: "openai",
|
||||
responseFactory() {
|
||||
upstreamHits += 1;
|
||||
return buildOpenAIResponse(false, `fresh-${upstreamHits}`);
|
||||
},
|
||||
});
|
||||
|
||||
const second = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
body: sharedBody,
|
||||
responseFormat: "openai",
|
||||
responseFactory() {
|
||||
upstreamHits += 1;
|
||||
return buildOpenAIResponse(false, `fresh-${upstreamHits}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(first.calls.length, 1);
|
||||
assert.equal(second.calls.length, 1);
|
||||
assert.equal(upstreamHits, 2);
|
||||
assert.equal(first.result.response.headers.get("X-OmniRoute-Cache"), "MISS");
|
||||
assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "MISS");
|
||||
|
||||
const payload = await second.result.response.json();
|
||||
assert.equal(payload.choices[0].message.content, "fresh-2");
|
||||
});
|
||||
|
||||
test("chatCore normalizes tool finish reasons and estimates usage when upstream omits it", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
|
||||
Reference in New Issue
Block a user