fix(cache): replay cached reasoning for tool-calling think models

Prevent upstream 400 failures when clients omit prior
reasoning_content in multi-turn tool-calling conversations.

Capture reasoning_content from streaming and non-streaming assistant
responses, persist it in a memory-plus-SQLite cache keyed by
tool_call_id, and re-inject it on later requests when available.

Add the reasoning cache migration, service layer, authenticated API
endpoints, dashboard tab, and unit coverage to support inspection,
cleanup, and crash recovery.
This commit is contained in:
diegosouzapw
2026-04-26 18:53:54 -03:00
parent e30969fecd
commit cabdfb04e7
11 changed files with 1467 additions and 11 deletions

View File

@@ -3013,6 +3013,27 @@ export async function handleChatCore({
}
}
// Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
try {
const firstChoice = translatedResponse?.choices?.[0];
const msg = firstChoice?.message;
if (
msg?.role === "assistant" &&
Array.isArray(msg.tool_calls) &&
msg.tool_calls.length > 0 &&
typeof msg.reasoning_content === "string" &&
msg.reasoning_content.length > 0
) {
const { cacheReasoningBatch } = require("../services/reasoningCache");
const toolIds = msg.tool_calls.map((tc: { id?: string }) => tc.id).filter(Boolean);
if (toolIds.length > 0) {
cacheReasoningBatch(toolIds, provider, model, msg.reasoning_content);
}
}
} catch {
// Cache capture is non-critical — never block the response
}
// Sanitize response for OpenAI SDK compatibility
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> and <thinking> tags into reasoning_content
@@ -3239,6 +3260,31 @@ export async function handleChatCore({
});
}
// Reasoning Replay Cache (#1628): Capture reasoning_content from streaming responses
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
if (streamStatus === 200 && streamResponseBody) {
try {
const body = streamResponseBody as Record<string, unknown>;
const choices = body.choices as { message?: Record<string, unknown> }[] | undefined;
const msg = choices?.[0]?.message;
if (
msg?.role === "assistant" &&
Array.isArray(msg.tool_calls) &&
msg.tool_calls.length > 0 &&
typeof msg.reasoning_content === "string" &&
(msg.reasoning_content as string).length > 0
) {
const { cacheReasoningBatch } = require("../services/reasoningCache");
const toolIds = (msg.tool_calls as { id?: string }[]).map((tc) => tc.id).filter(Boolean);
if (toolIds.length > 0) {
cacheReasoningBatch(toolIds, provider, model, msg.reasoning_content as string);
}
}
} catch {
// Cache capture is non-critical — never block the stream
}
}
// Track cache token metrics for streaming responses
if (streamUsage && typeof streamUsage === "object") {
const inputTokens = streamUsage.prompt_tokens || 0;

View File

@@ -0,0 +1,314 @@
/**
* Reasoning Replay Cache — In-memory + DB hybrid service.
*
* Captures reasoning_content from thinking-mode model responses (DeepSeek V4,
* Kimi K2, Qwen-Thinking, GLM, etc.) and re-injects it on subsequent turns
* when clients omit it. This prevents the DeepSeek 400 error:
* "The reasoning_content in the thinking mode must be passed back to the API."
*
* Architecture:
* Write: memory + DB simultaneously (fire-and-forget DB write)
* Read: memory first → DB fallback → miss
*
* @see Issue #1628
*/
// ──────────────── Provider/Model Detection ────────────────
const REASONING_REPLAY_PROVIDERS = new Set([
"deepseek",
"opencode-go",
"siliconflow",
"nebius",
"deepinfra",
"sambanova",
"fireworks",
"together",
]);
const REASONING_REPLAY_MODEL_PATTERNS = [
/deepseek-r1/i,
/deepseek-reasoner/i,
/deepseek-chat/i,
/kimi-k2/i,
/qwq/i,
/qwen.*think/i,
/glm.*think/i,
];
/**
* Check if a provider/model combination requires reasoning replay.
*/
export function requiresReasoningReplay(provider: string, model: string): boolean {
if (REASONING_REPLAY_PROVIDERS.has(provider)) return true;
return REASONING_REPLAY_MODEL_PATTERNS.some((p) => p.test(model));
}
// ──────────────── In-Memory Cache ────────────────
interface MemoryCacheEntry {
reasoning: string;
provider: string;
model: string;
expiresAt: number;
createdAt: number;
}
const memoryCache = new Map<string, MemoryCacheEntry>();
const MAX_MEMORY_ENTRIES = 2000;
const TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
// ──────────────── Counters ────────────────
let hits = 0;
let misses = 0;
let replays = 0;
// ──────────────── Core Operations ────────────────
/**
* Evict the oldest entry from the memory cache when full.
*/
function evictOldest(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of memoryCache) {
if (entry.createdAt < oldestTime) {
oldestTime = entry.createdAt;
oldestKey = key;
}
}
if (oldestKey) memoryCache.delete(oldestKey);
}
/**
* Remove expired entries from memory cache.
*/
function purgeExpiredMemory(): void {
const now = Date.now();
for (const [key, entry] of memoryCache) {
if (now >= entry.expiresAt) {
memoryCache.delete(key);
}
}
}
/**
* Cache a reasoning_content string for one or more tool_call IDs.
* Writes to both memory and DB (DB write is fire-and-forget).
*/
export function cacheReasoning(
toolCallId: string,
provider: string,
model: string,
reasoning: string
): void {
if (!toolCallId || !reasoning) return;
const now = Date.now();
// Memory write
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
evictOldest();
}
memoryCache.set(toolCallId, {
reasoning,
provider,
model,
expiresAt: now + TTL_MS,
createdAt: now,
});
// DB write (fire-and-forget — don't block the hot path)
try {
const { setReasoningCache } = require("@/lib/db/reasoningCache");
setReasoningCache(toolCallId, provider, model, reasoning, TTL_MS);
} catch {
// DB write failure is non-fatal; memory cache still serves
}
}
/**
* Cache reasoning for multiple tool_call IDs (same reasoning content).
*/
export function cacheReasoningBatch(
toolCallIds: string[],
provider: string,
model: string,
reasoning: string
): void {
for (const id of toolCallIds) {
if (id) cacheReasoning(id, provider, model, reasoning);
}
}
/**
* Look up cached reasoning_content by tool_call_id.
* Memory first → DB fallback → null (miss).
*/
export function lookupReasoning(toolCallId: string): string | null {
if (!toolCallId) {
misses++;
return null;
}
// 1. Check memory
const mem = memoryCache.get(toolCallId);
if (mem) {
if (Date.now() < mem.expiresAt) {
hits++;
return mem.reasoning;
}
// Expired in memory — remove
memoryCache.delete(toolCallId);
}
// 2. Fallback to DB
try {
const { getReasoningCache } = require("@/lib/db/reasoningCache");
const dbResult = getReasoningCache(toolCallId);
if (dbResult) {
hits++;
// Promote back to memory for fast subsequent lookups
memoryCache.set(toolCallId, {
reasoning: dbResult.reasoning,
provider: dbResult.provider,
model: dbResult.model,
expiresAt: Date.now() + TTL_MS,
createdAt: Date.now(),
});
return dbResult.reasoning;
}
} catch {
// DB read failure is non-fatal
}
// 3. Miss
misses++;
return null;
}
/**
* Record that a replay was performed (for dashboard metrics).
*/
export function recordReplay(): void {
replays++;
}
// ──────────────── Stats & Dashboard ────────────────
/**
* Get combined stats from memory + DB + counters.
*/
export function getReasoningCacheServiceStats(): {
memoryEntries: number;
dbEntries: number;
totalEntries: number;
totalChars: number;
hits: number;
misses: number;
replays: number;
replayRate: string;
byProvider: Record<string, { entries: number; chars: number }>;
byModel: Record<string, { entries: number; chars: number }>;
oldestEntry: string | null;
newestEntry: string | null;
} {
// Purge expired memory entries before reporting
purgeExpiredMemory();
let dbStats = {
totalEntries: 0,
totalChars: 0,
byProvider: {} as Record<string, { entries: number; chars: number }>,
byModel: {} as Record<string, { entries: number; chars: number }>,
oldestEntry: null as string | null,
newestEntry: null as string | null,
};
try {
const { getReasoningCacheStats } = require("@/lib/db/reasoningCache");
dbStats = getReasoningCacheStats();
} catch {
// DB unavailable — return memory-only stats
}
const totalLookups = hits + misses;
const replayRate = totalLookups > 0 ? ((replays / totalLookups) * 100).toFixed(1) : "0.0";
return {
memoryEntries: memoryCache.size,
dbEntries: dbStats.totalEntries,
totalEntries: dbStats.totalEntries,
totalChars: dbStats.totalChars,
hits,
misses,
replays,
replayRate: `${replayRate}%`,
byProvider: dbStats.byProvider,
byModel: dbStats.byModel,
oldestEntry: dbStats.oldestEntry,
newestEntry: dbStats.newestEntry,
};
}
/**
* Get paginated entries (delegates to DB).
*/
export function getReasoningCacheServiceEntries(
opts: {
limit?: number;
offset?: number;
provider?: string;
model?: string;
} = {}
): unknown[] {
try {
const { getReasoningCacheEntries } = require("@/lib/db/reasoningCache");
return getReasoningCacheEntries(opts);
} catch {
return [];
}
}
/**
* Clear all reasoning cache entries (memory + DB).
* Returns count of DB entries removed.
*/
export function clearReasoningCacheAll(provider?: string): number {
// Clear memory
if (provider) {
for (const [key, entry] of memoryCache) {
if (entry.provider === provider) memoryCache.delete(key);
}
} else {
memoryCache.clear();
}
// Reset counters
hits = 0;
misses = 0;
replays = 0;
// Clear DB
try {
const { clearAllReasoningCache } = require("@/lib/db/reasoningCache");
return clearAllReasoningCache(provider);
} catch {
return 0;
}
}
/**
* Cleanup expired entries from both memory and DB.
* Called periodically (e.g., every 30 min from health-check).
*/
export function cleanupReasoningCache(): number {
purgeExpiredMemory();
try {
const { cleanupExpiredReasoning } = require("@/lib/db/reasoningCache");
return cleanupExpiredReasoning();
} catch {
return 0;
}
}

View File

@@ -204,21 +204,51 @@ export function translateRequest(
result.tools = sanitizeToolDescriptions(result.tools);
}
// Inject reasoning_content = "" for DeepSeek/Reasoning models assistant messages with tool_calls
// if omitted by the client, to avoid upstream 400 errors (e.g. "Messages with role 'assistant' that contain tool_calls must also include reasoning_content")
// Reasoning Replay Cache (#1628): Re-inject cached reasoning_content for
// thinking-mode models (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) when
// clients omit it from the conversation history. Without this, DeepSeek V4
// returns 400: "The reasoning_content in the thinking mode must be passed
// back to the API."
const isReasoner =
provider === "deepseek" ||
provider === "opencode-go" ||
(typeof model === "string" && /r1|reason|kimi-k2/i.test(model));
if (isReasoner && result.messages && Array.isArray(result.messages)) {
// Lazy-load to avoid circular dependency issues at module load
let lookupReasoning: ((id: string) => string | null) | null = null;
let recordReplay: (() => void) | null = null;
try {
const cache = require("../services/reasoningCache");
lookupReasoning = cache.lookupReasoning;
recordReplay = cache.recordReplay;
} catch {
// Cache module unavailable — fall through to legacy behavior
}
for (const msg of result.messages) {
if (
msg.role === "assistant" &&
Array.isArray(msg.tool_calls) &&
msg.tool_calls.length > 0 &&
msg.reasoning_content === undefined
) {
msg.reasoning_content = "";
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
// Skip if client already provided real reasoning_content
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
continue;
}
// Try cache lookup using first tool_call ID
if (lookupReasoning) {
const firstToolId = msg.tool_calls[0]?.id;
if (firstToolId) {
const cached = lookupReasoning(firstToolId);
if (cached) {
msg.reasoning_content = cached;
if (recordReplay) recordReplay();
continue;
}
}
}
// Legacy fallback — empty string (works for older DeepSeek versions)
if (msg.reasoning_content === undefined) {
msg.reasoning_content = "";
}
}
}
} else if (

View File

@@ -0,0 +1,482 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// ──────────────── Types ────────────────
interface ReasoningCacheEntry {
toolCallId: string;
provider: string;
model: string;
reasoning: string;
charCount: number;
createdAt: string;
expiresAt: string;
}
interface ReasoningCacheStats {
memoryEntries: number;
dbEntries: number;
totalEntries: number;
totalChars: number;
hits: number;
misses: number;
replays: number;
replayRate: string;
byProvider: Record<string, { entries: number; chars: number }>;
byModel: Record<string, { entries: number; chars: number }>;
oldestEntry: string | null;
newestEntry: string | null;
}
interface ReasoningCacheData {
stats: ReasoningCacheStats;
entries: ReasoningCacheEntry[];
}
// ──────────────── Helpers ────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatChars(chars: number): string {
if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`;
if (chars >= 1_000) return `${(chars / 1_000).toFixed(1)}K`;
return String(chars);
}
// ──────────────── Sub-Components ────────────────
function StatCard({
icon,
label,
value,
sub,
accent = "text-text-main",
}: {
icon: string;
label: string;
value: string | number;
sub?: string;
accent?: string;
}) {
return (
<div className="rounded-2xl border border-border/30 bg-surface-raised/70 p-4">
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-base leading-none" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
<div className={`mt-3 text-2xl font-semibold tabular-nums ${accent}`}>{value}</div>
{sub && <div className="mt-1 text-xs text-text-muted">{sub}</div>}
</div>
);
}
function SectionBadge({
icon,
children,
tone = "neutral",
}: {
icon: string;
children: React.ReactNode;
tone?: "neutral" | "green" | "amber" | "blue";
}) {
const toneClass =
tone === "green"
? "border-green-500/20 bg-green-500/10 text-green-300"
: tone === "amber"
? "border-amber-400/20 bg-amber-400/10 text-amber-300"
: tone === "blue"
? "border-blue-400/20 bg-blue-400/10 text-blue-300"
: "border-border/40 bg-surface/50 text-text-muted";
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.12em] ${toneClass}`}
>
<span className="material-symbols-outlined text-sm leading-none" aria-hidden="true">
{icon}
</span>
{children}
</span>
);
}
function InfoRow({ icon, children }: { icon: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 text-sm text-text-muted">
<span
className="material-symbols-outlined shrink-0 text-base leading-5 text-blue-400"
aria-hidden="true"
>
{icon}
</span>
<span>{children}</span>
</div>
);
}
// ──────────────── Main Component ────────────────
const REFRESH_INTERVAL_MS = 10_000;
export default function ReasoningCacheTab() {
const t = useTranslations("cache");
const notify = useNotificationStore();
const [data, setData] = useState<ReasoningCacheData | null>(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/cache/reasoning");
if (res.ok) {
const json: ReasoningCacheData = await res.json();
setData(json);
}
} catch (error) {
console.error("[ReasoningCacheTab] Failed to fetch:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchData();
const id = setInterval(() => void fetchData(), REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [fetchData]);
const handleClear = async () => {
setClearing(true);
try {
const res = await fetch("/api/cache/reasoning", { method: "DELETE" });
if (res.ok) {
const result = await res.json();
notify.success(t("reasoningClearSuccess", { count: result.cleared ?? 0 }));
await fetchData();
} else {
notify.error(t("reasoningClearError"));
}
} catch {
notify.error(t("reasoningClearError"));
} finally {
setClearing(false);
}
};
if (loading) {
return (
<div className="space-y-4" aria-busy="true">
<div className="h-32 rounded-2xl bg-surface-raised animate-pulse" />
<div className="h-48 rounded-2xl bg-surface-raised animate-pulse" />
</div>
);
}
if (!data) {
return (
<EmptyState
icon="psychology"
title={t("reasoningCache")}
description={t("reasoningNoData")}
actionLabel={t("refresh")}
onAction={() => void fetchData()}
/>
);
}
const { stats, entries } = data;
const providerEntries = Object.entries(stats.byProvider).sort(
([, a], [, b]) => b.entries - a.entries
);
const modelEntries = Object.entries(stats.byModel).sort(([, a], [, b]) => b.entries - a.entries);
const totalLookups = stats.hits + stats.misses;
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<SectionBadge icon="psychology" tone="blue">
{t("reasoningCache")}
</SectionBadge>
<div>
<h2 className="text-lg font-semibold text-text-main">{t("reasoningCache")}</h2>
<p className="mt-1 max-w-3xl text-sm text-text-muted">{t("reasoningCacheDesc")}</p>
</div>
</div>
<Button
variant="danger"
icon="delete_sweep"
size="sm"
onClick={() => void handleClear()}
disabled={clearing || loading || stats.totalEntries === 0}
loading={clearing}
aria-label={t("reasoningClearAll")}
>
{t("reasoningClearAll")}
</Button>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
<StatCard
icon="psychology"
label={t("reasoningEntries")}
value={stats.totalEntries}
sub={`${stats.memoryEntries} memory / ${stats.dbEntries} DB`}
accent="text-blue-400"
/>
<StatCard
icon="speed"
label={t("reasoningReplayRate")}
value={stats.replayRate}
sub={`${totalLookups.toLocaleString()} lookups`}
accent="text-emerald-500"
/>
<StatCard
icon="replay"
label={t("reasoningReplays")}
value={stats.replays.toLocaleString()}
sub={t("reasoningBehaviorReplay")}
accent="text-cyan-400"
/>
<StatCard
icon="text_fields"
label={t("reasoningCharsCached")}
value={formatChars(stats.totalChars)}
sub={`${stats.totalChars.toLocaleString()} chars`}
accent="text-purple-400"
/>
<StatCard
icon="error_outline"
label={t("reasoningMisses")}
value={stats.misses.toLocaleString()}
sub={`${stats.hits.toLocaleString()} hits`}
accent="text-red-400"
/>
</div>
{/* By Provider */}
{providerEntries.length > 0 && (
<div className="rounded-2xl border border-border/30 bg-surface/20 p-5">
<h3 className="text-sm font-medium text-text-main">{t("reasoningByProvider")}</h3>
<div className="mt-3 overflow-x-auto rounded-2xl border border-border/20 bg-surface/35">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
<th className="px-4 py-3">Provider</th>
<th className="px-4 py-3">{t("reasoningEntries")}</th>
<th className="px-4 py-3">{t("reasoningChars")}</th>
<th className="px-4 py-3">Share</th>
</tr>
</thead>
<tbody>
{providerEntries.map(([prov, d]) => {
const share =
stats.totalEntries > 0
? ((d.entries / stats.totalEntries) * 100).toFixed(1)
: "0.0";
return (
<tr key={prov} className="border-b border-border/15 last:border-b-0">
<td className="px-4 py-3 font-medium text-text-main">{prov}</td>
<td className="px-4 py-3 tabular-nums text-text-main">
{d.entries.toLocaleString()}
</td>
<td className="px-4 py-3 tabular-nums text-purple-400">
{formatChars(d.chars)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-2 w-20 overflow-hidden rounded-full bg-surface/60">
<div
className="h-full rounded-full bg-blue-400"
style={{
width: `${Math.min(parseFloat(share), 100)}%`,
}}
/>
</div>
<span className="text-xs font-semibold tabular-nums text-text-main">
{share}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* By Model */}
{modelEntries.length > 0 && (
<div className="rounded-2xl border border-border/30 bg-surface/20 p-5">
<h3 className="text-sm font-medium text-text-main">{t("reasoningByModel")}</h3>
<div className="mt-3 overflow-x-auto rounded-2xl border border-border/20 bg-surface/35">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
<th className="px-4 py-3">Model</th>
<th className="px-4 py-3">{t("reasoningEntries")}</th>
<th className="px-4 py-3">Avg Chars</th>
<th className="px-4 py-3">{t("reasoningChars")}</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([mdl, d]) => {
const avgChars = d.entries > 0 ? Math.round(d.chars / d.entries) : 0;
return (
<tr key={mdl} className="border-b border-border/15 last:border-b-0">
<td className="px-4 py-3 font-medium text-text-main">{mdl}</td>
<td className="px-4 py-3 tabular-nums text-text-main">
{d.entries.toLocaleString()}
</td>
<td className="px-4 py-3 tabular-nums text-cyan-400">
{avgChars.toLocaleString()}
</td>
<td className="px-4 py-3 tabular-nums text-purple-400">
{formatChars(d.chars)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Recent Entries */}
<div className="rounded-2xl border border-border/30 bg-surface/20 p-5">
<div className="mb-4 flex flex-col gap-1">
<h3 className="text-sm font-medium text-text-main">{t("reasoningRecentEntries")}</h3>
<p className="text-sm text-text-muted">{t("reasoningCacheDesc")}</p>
</div>
{entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/40 bg-surface/10 px-4 py-6 text-sm text-text-muted">
{t("reasoningNoData")}
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-border/20 bg-surface/35">
<div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/20 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-text-muted">
<span>{t("reasoningToolCallId")}</span>
<span>Provider</span>
<span>Model</span>
<span>{t("reasoningChars")}</span>
<span>{t("reasoningAge")}</span>
<span />
</div>
<div className="max-h-96 overflow-y-auto">
{entries.map((entry) => (
<div key={entry.toolCallId}>
<div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/15 px-4 py-3 last:border-b-0">
<div
className="truncate text-sm font-mono text-text-main"
title={entry.toolCallId}
>
{entry.toolCallId}
</div>
<div className="text-sm text-text-muted">{entry.provider}</div>
<div className="truncate text-sm text-text-muted" title={entry.model}>
{entry.model}
</div>
<div className="text-sm tabular-nums text-purple-400">
{entry.charCount.toLocaleString()}
</div>
<div className="text-sm text-text-muted">{timeAgo(entry.createdAt)}</div>
<button
type="button"
onClick={() =>
setExpandedId(expandedId === entry.toolCallId ? null : entry.toolCallId)
}
className="flex items-center justify-center rounded-md p-1 text-text-muted transition-colors hover:bg-surface/60 hover:text-text-main"
aria-label={t("reasoningView")}
>
<span className="material-symbols-outlined text-lg">
{expandedId === entry.toolCallId ? "expand_less" : "visibility"}
</span>
</button>
</div>
{/* Expanded Detail */}
{expandedId === entry.toolCallId && (
<div className="border-b border-border/15 bg-surface/15 px-4 py-4">
<div className="flex items-center gap-2 text-xs text-text-muted mb-2">
<span className="material-symbols-outlined text-sm text-blue-400">
psychology
</span>
<span className="font-medium">
{t("reasoningDetail")} ({entry.toolCallId})
</span>
</div>
<pre className="max-h-72 overflow-auto rounded-xl bg-black/20 p-4 text-xs leading-relaxed text-text-main font-mono whitespace-pre-wrap break-words">
{entry.reasoning}
</pre>
<div className="mt-3 flex flex-wrap gap-4 text-xs text-text-muted">
<span>
Provider: <span className="text-text-main">{entry.provider}</span>
</span>
<span>
Model: <span className="text-text-main">{entry.model}</span>
</span>
<span>
Created:{" "}
<span className="text-text-main">
{new Date(entry.createdAt).toLocaleString()}
</span>
</span>
<span>
Expires:{" "}
<span className="text-text-main">
{new Date(entry.expiresAt).toLocaleString()}
</span>
</span>
<span>
{t("reasoningChars")}:{" "}
<span className="text-purple-400">
{entry.charCount.toLocaleString()}
</span>
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Behavior Info */}
<div className="rounded-2xl border border-border/30 bg-surface/20 p-5">
<h3 className="text-sm font-medium text-text-main">{t("reasoningBehavior")}</h3>
<div className="mt-4 grid gap-3">
<InfoRow icon="info">{t("reasoningBehaviorCapture")}</InfoRow>
<InfoRow icon="info">{t("reasoningBehaviorReplay")}</InfoRow>
<InfoRow icon="info">{t("reasoningBehaviorFallback")}</InfoRow>
<InfoRow icon="info">{t("reasoningBehaviorTtl")}</InfoRow>
<InfoRow icon="info">{t("reasoningBehaviorModels")}</InfoRow>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import CacheEntriesTab from "./components/CacheEntriesTab";
import ReasoningCacheTab from "./components/ReasoningCacheTab";
interface SemanticCacheStats {
memoryEntries: number;
@@ -63,7 +64,7 @@ interface CacheStats {
config?: CacheConfig;
}
type CacheView = "prompt" | "semantic";
type CacheView = "prompt" | "semantic" | "reasoning";
function StatCard({
icon,
@@ -469,6 +470,18 @@ export default function CachePage() {
>
{t("semanticCache")}
</button>
<button
type="button"
onClick={() => setActiveView("reasoning")}
aria-pressed={activeView === "reasoning"}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeView === "reasoning"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
{t("reasoningCache")}
</button>
</div>
{loading && (
@@ -818,6 +831,14 @@ export default function CachePage() {
</div>
</Card>
)}
{activeView === "reasoning" && (
<Card className="border border-border/30 bg-surface-raised/40 backdrop-blur-sm rounded-3xl overflow-hidden">
<div className="p-6">
<ReasoningCacheTab />
</div>
</Card>
)}
</div>
);
}

71
src/app/api/cache/reasoning/route.ts vendored Normal file
View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* GET /api/cache/reasoning
*
* Returns reasoning replay cache stats + paginated entries.
* Query params: ?provider=deepseek&model=deepseek-reasoner&limit=50&offset=0
*/
export async function GET(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { getReasoningCacheServiceStats, getReasoningCacheServiceEntries } =
await import("@omniroute/open-sse/services/reasoningCache");
const { searchParams } = new URL(req.url);
const provider = searchParams.get("provider") || undefined;
const model = searchParams.get("model") || undefined;
const limit = parseInt(searchParams.get("limit") || "50", 10);
const offset = parseInt(searchParams.get("offset") || "0", 10);
const stats = getReasoningCacheServiceStats();
const entries = getReasoningCacheServiceEntries({
limit: Math.min(Math.max(limit, 1), 200),
offset: Math.max(offset, 0),
provider,
model,
});
return NextResponse.json({ stats, entries });
} catch (error) {
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
}
}
/**
* DELETE /api/cache/reasoning
*
* Clears reasoning cache entries.
* Query params: ?provider=deepseek (filter by provider) or no params (clear all).
*/
export async function DELETE(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { clearReasoningCacheAll } = await import("@omniroute/open-sse/services/reasoningCache");
const { searchParams } = new URL(req.url);
const provider = searchParams.get("provider") || undefined;
const cleared = clearReasoningCacheAll(provider);
return NextResponse.json({
ok: true,
cleared,
scope: provider ? "provider" : "all",
...(provider ? { provider } : {}),
});
} catch (error) {
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
}
}

View File

@@ -4431,7 +4431,32 @@
"savedCalls": "Saved API Calls",
"totalProcessed": "Total Requests Processed",
"disabled": "Disabled",
"totalRequests": "Total Requests"
"totalRequests": "Total Requests",
"reasoningCache": "Reasoning Replay",
"reasoningCacheDesc": "Preserves model thinking for multi-turn tool calling flows",
"reasoningEntries": "Active Entries",
"reasoningReplayRate": "Replay Rate",
"reasoningReplays": "Total Replays",
"reasoningCharsCached": "Characters Cached",
"reasoningMisses": "Cache Misses",
"reasoningByProvider": "By Provider",
"reasoningByModel": "By Model",
"reasoningRecentEntries": "Recent Entries",
"reasoningToolCallId": "Tool Call ID",
"reasoningChars": "Characters",
"reasoningAge": "Age",
"reasoningView": "View",
"reasoningDetail": "Reasoning Content",
"reasoningBehavior": "Behavior",
"reasoningBehaviorCapture": "Captures reasoning_content from streaming responses",
"reasoningBehaviorReplay": "Re-injects on next turn when client omits it",
"reasoningBehaviorFallback": "Memory-first with SQLite fallback for crash recovery",
"reasoningBehaviorTtl": "TTL: 2 hours | Max entries: 2,000 (memory)",
"reasoningBehaviorModels": "Supported: DeepSeek, Kimi, Qwen-Thinking, GLM",
"reasoningClearAll": "Clear Reasoning Cache",
"reasoningClearSuccess": "Cleared {count} reasoning cache entries",
"reasoningClearError": "Failed to clear reasoning cache",
"reasoningNoData": "No reasoning entries cached yet. Entries appear when thinking models use tool calling."
},
"proxyConfigModal": {
"levelGlobal": "Global",

View File

@@ -0,0 +1,27 @@
-- 032_create_reasoning_cache.sql
-- Persistent storage for reasoning_content replay cache.
-- Enables crash recovery and dashboard visibility for the
-- Reasoning Replay Cache feature (Issue #1628).
--
-- When thinking-mode models (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.)
-- generate tool_calls, their reasoning_content must be replayed on the
-- next turn. This table persists that content keyed by tool_call_id.
CREATE TABLE IF NOT EXISTS reasoning_cache (
tool_call_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
model TEXT NOT NULL,
reasoning TEXT NOT NULL,
char_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reasoning_cache_expires
ON reasoning_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_reasoning_cache_provider
ON reasoning_cache(provider);
CREATE INDEX IF NOT EXISTS idx_reasoning_cache_model
ON reasoning_cache(model);
CREATE INDEX IF NOT EXISTS idx_reasoning_cache_created
ON reasoning_cache(created_at);

View File

@@ -0,0 +1,240 @@
/**
* reasoningCache.ts — DB domain module for the Reasoning Replay Cache.
*
* Persists reasoning_content from thinking-mode models (DeepSeek V4, Kimi K2,
* Qwen-Thinking, etc.) keyed by tool_call_id. Used for crash recovery and
* dashboard visibility. The hot path uses the in-memory cache in
* open-sse/services/reasoningCache.ts; this module is the persistence layer.
*
* @see Issue #1628
*/
import { getDbInstance } from "./core";
// ──────────────── Types ────────────────
export interface ReasoningCacheEntry {
toolCallId: string;
provider: string;
model: string;
reasoning: string;
charCount: number;
createdAt: string;
expiresAt: string;
}
export interface ReasoningCacheStats {
totalEntries: number;
totalChars: number;
byProvider: Record<string, { entries: number; chars: number }>;
byModel: Record<string, { entries: number; chars: number }>;
oldestEntry: string | null;
newestEntry: string | null;
}
// ──────────────── Constants ────────────────
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
// ──────────────── CRUD ────────────────
/**
* Store a reasoning_content entry for a given tool_call_id.
* Uses INSERT OR REPLACE to handle duplicate tool_call_ids gracefully.
*/
export function setReasoningCache(
toolCallId: string,
provider: string,
model: string,
reasoning: string,
ttlMs: number = DEFAULT_TTL_MS
): void {
const db = getDbInstance();
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
const charCount = reasoning.length;
db.prepare(
`INSERT OR REPLACE INTO reasoning_cache
(tool_call_id, provider, model, reasoning, char_count, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), ?)`
).run(toolCallId, provider, model, reasoning, charCount, expiresAt);
}
/**
* Retrieve a cached reasoning_content by tool_call_id.
* Returns null if not found or expired.
*/
export function getReasoningCache(
toolCallId: string
): { reasoning: string; provider: string; model: string } | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT reasoning, provider, model FROM reasoning_cache
WHERE tool_call_id = ? AND expires_at > datetime('now')`
)
.get(toolCallId) as { reasoning: string; provider: string; model: string } | undefined;
return row ?? null;
}
/**
* Delete a specific reasoning cache entry.
*/
export function deleteReasoningCache(toolCallId: string): void {
const db = getDbInstance();
db.prepare(`DELETE FROM reasoning_cache WHERE tool_call_id = ?`).run(toolCallId);
}
/**
* Delete all expired entries. Returns count of rows removed.
*/
export function cleanupExpiredReasoning(): number {
const db = getDbInstance();
const result = db
.prepare(`DELETE FROM reasoning_cache WHERE expires_at <= datetime('now')`)
.run();
return result.changes;
}
/**
* Delete all entries, optionally filtered by provider.
* Returns count of rows removed.
*/
export function clearAllReasoningCache(provider?: string): number {
const db = getDbInstance();
if (provider) {
const result = db.prepare(`DELETE FROM reasoning_cache WHERE provider = ?`).run(provider);
return result.changes;
}
const result = db.prepare(`DELETE FROM reasoning_cache`).run();
return result.changes;
}
// ──────────────── Stats ────────────────
/**
* Get aggregate statistics for the reasoning cache.
*/
export function getReasoningCacheStats(): ReasoningCacheStats {
const db = getDbInstance();
// Total counts
const totals = db
.prepare(
`SELECT COUNT(*) as total_entries, COALESCE(SUM(char_count), 0) as total_chars
FROM reasoning_cache WHERE expires_at > datetime('now')`
)
.get() as { total_entries: number; total_chars: number };
// By provider
const providerRows = db
.prepare(
`SELECT provider, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars
FROM reasoning_cache WHERE expires_at > datetime('now')
GROUP BY provider ORDER BY entries DESC`
)
.all() as { provider: string; entries: number; chars: number }[];
const byProvider: Record<string, { entries: number; chars: number }> = {};
for (const row of providerRows) {
byProvider[row.provider] = { entries: row.entries, chars: row.chars };
}
// By model
const modelRows = db
.prepare(
`SELECT model, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars
FROM reasoning_cache WHERE expires_at > datetime('now')
GROUP BY model ORDER BY entries DESC`
)
.all() as { model: string; entries: number; chars: number }[];
const byModel: Record<string, { entries: number; chars: number }> = {};
for (const row of modelRows) {
byModel[row.model] = { entries: row.entries, chars: row.chars };
}
// Oldest/newest
const oldest = db
.prepare(
`SELECT created_at FROM reasoning_cache
WHERE expires_at > datetime('now') ORDER BY created_at ASC LIMIT 1`
)
.get() as { created_at: string } | undefined;
const newest = db
.prepare(
`SELECT created_at FROM reasoning_cache
WHERE expires_at > datetime('now') ORDER BY created_at DESC LIMIT 1`
)
.get() as { created_at: string } | undefined;
return {
totalEntries: totals.total_entries,
totalChars: totals.total_chars,
byProvider,
byModel,
oldestEntry: oldest?.created_at ?? null,
newestEntry: newest?.created_at ?? null,
};
}
// ──────────────── Paginated Entries ────────────────
/**
* List reasoning cache entries with optional filters and pagination.
*/
export function getReasoningCacheEntries(
opts: {
limit?: number;
offset?: number;
provider?: string;
model?: string;
} = {}
): ReasoningCacheEntry[] {
const db = getDbInstance();
const limit = Math.min(opts.limit ?? 50, 200);
const offset = opts.offset ?? 0;
const conditions: string[] = ["expires_at > datetime('now')"];
const params: unknown[] = [];
if (opts.provider) {
conditions.push("provider = ?");
params.push(opts.provider);
}
if (opts.model) {
conditions.push("model = ?");
params.push(opts.model);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = db
.prepare(
`SELECT tool_call_id, provider, model, reasoning, char_count, created_at, expires_at
FROM reasoning_cache ${where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as {
tool_call_id: string;
provider: string;
model: string;
reasoning: string;
char_count: number;
created_at: string;
expires_at: string;
}[];
return rows.map((row) => ({
toolCallId: row.tool_call_id,
provider: row.provider,
model: row.model,
reasoning: row.reasoning,
charCount: row.char_count,
createdAt: row.created_at,
expiresAt: row.expires_at,
}));
}

View File

@@ -321,3 +321,16 @@ export {
getAllPersistedCreditBalances,
persistCreditBalance,
} from "./db/creditBalance";
export {
// Reasoning Replay Cache (#1628)
setReasoningCache,
getReasoningCache,
deleteReasoningCache,
cleanupExpiredReasoning,
getReasoningCacheStats,
getReasoningCacheEntries,
clearAllReasoningCache,
} from "./db/reasoningCache";
export type { ReasoningCacheEntry, ReasoningCacheStats } from "./db/reasoningCache";

View File

@@ -0,0 +1,187 @@
/**
* Unit tests for the Reasoning Replay Cache (Issue #1628).
*
* Covers: memory cache, DB fallback, hit/miss counters,
* provider detection, and cleanup behavior.
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
// ──────────── Direct service import ────────────
import {
cacheReasoning,
cacheReasoningBatch,
lookupReasoning,
recordReplay,
getReasoningCacheServiceStats,
clearReasoningCacheAll,
requiresReasoningReplay,
cleanupReasoningCache,
} from "../../open-sse/services/reasoningCache.ts";
describe("Reasoning Replay Cache — Service Layer", () => {
before(() => {
// Start each suite with a clean slate
clearReasoningCacheAll();
});
after(() => {
clearReasoningCacheAll();
});
it("should store and retrieve reasoning by tool_call_id", () => {
cacheReasoning(
"call_test_1",
"deepseek",
"deepseek-reasoner",
"The user wants to read the file..."
);
const result = lookupReasoning("call_test_1");
assert.equal(result, "The user wants to read the file...");
});
it("should return null for unknown tool_call_id", () => {
const result = lookupReasoning("call_nonexistent");
assert.equal(result, null);
});
it("should return null for empty tool_call_id", () => {
const result = lookupReasoning("");
assert.equal(result, null);
});
it("should skip caching when reasoning is empty", () => {
cacheReasoning("call_empty", "deepseek", "deepseek-chat", "");
const result = lookupReasoning("call_empty");
assert.equal(result, null);
});
it("should cache reasoning for multiple tool_call_ids (batch)", () => {
cacheReasoningBatch(
["call_batch_1", "call_batch_2", "call_batch_3"],
"deepseek",
"deepseek-reasoner",
"Batch reasoning content"
);
assert.equal(lookupReasoning("call_batch_1"), "Batch reasoning content");
assert.equal(lookupReasoning("call_batch_2"), "Batch reasoning content");
assert.equal(lookupReasoning("call_batch_3"), "Batch reasoning content");
});
it("should not overwrite if same tool_call_id is cached again", () => {
cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "First reasoning");
cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "Updated reasoning");
// Second write wins (INSERT OR REPLACE)
const result = lookupReasoning("call_overwrite");
assert.equal(result, "Updated reasoning");
});
it("should track hits and misses correctly", () => {
clearReasoningCacheAll();
cacheReasoning("call_hit_test", "deepseek", "deepseek-chat", "test reasoning");
lookupReasoning("call_hit_test"); // hit
lookupReasoning("call_hit_test"); // hit
lookupReasoning("call_miss_test"); // miss
const stats = getReasoningCacheServiceStats();
assert.ok(stats.hits >= 2, `Expected at least 2 hits, got ${stats.hits}`);
assert.ok(stats.misses >= 1, `Expected at least 1 miss, got ${stats.misses}`);
});
it("should track replays", () => {
clearReasoningCacheAll();
recordReplay();
recordReplay();
recordReplay();
const stats = getReasoningCacheServiceStats();
assert.ok(stats.replays >= 3, `Expected at least 3 replays, got ${stats.replays}`);
});
it("should report correct stats structure", () => {
clearReasoningCacheAll();
cacheReasoning("call_stat_1", "deepseek", "deepseek-reasoner", "Reasoning A");
cacheReasoning("call_stat_2", "kimi", "kimi-k2.5", "Reasoning B from Kimi");
const stats = getReasoningCacheServiceStats();
assert.equal(typeof stats.memoryEntries, "number");
assert.equal(typeof stats.dbEntries, "number");
assert.equal(typeof stats.totalEntries, "number");
assert.equal(typeof stats.totalChars, "number");
assert.equal(typeof stats.hits, "number");
assert.equal(typeof stats.misses, "number");
assert.equal(typeof stats.replays, "number");
assert.equal(typeof stats.replayRate, "string");
assert.ok(stats.replayRate.endsWith("%"));
assert.equal(typeof stats.byProvider, "object");
assert.equal(typeof stats.byModel, "object");
});
it("should clear all entries", () => {
cacheReasoning("call_clear_1", "deepseek", "deepseek-chat", "Will be cleared");
cacheReasoning("call_clear_2", "deepseek", "deepseek-chat", "Also cleared");
const count = clearReasoningCacheAll();
assert.ok(count >= 0);
assert.equal(lookupReasoning("call_clear_1"), null);
assert.equal(lookupReasoning("call_clear_2"), null);
});
it("should cleanup expired reasoning (no-op when nothing expired)", () => {
cacheReasoning("call_cleanup_test", "deepseek", "deepseek-chat", "Not expired yet");
const cleaned = cleanupReasoningCache();
assert.equal(typeof cleaned, "number");
// Entry should still be available since TTL is 2 hours
assert.equal(lookupReasoning("call_cleanup_test"), "Not expired yet");
});
});
describe("Reasoning Replay Cache — Provider Detection", () => {
it("should detect deepseek as requiring replay", () => {
assert.equal(requiresReasoningReplay("deepseek", "deepseek-chat"), true);
});
it("should detect opencode-go as requiring replay", () => {
assert.equal(requiresReasoningReplay("opencode-go", "some-model"), true);
});
it("should detect siliconflow as requiring replay", () => {
assert.equal(requiresReasoningReplay("siliconflow", "deepseek-r1"), true);
});
it("should detect deepseek-r1 model pattern", () => {
assert.equal(requiresReasoningReplay("unknown-provider", "deepseek-r1"), true);
});
it("should detect deepseek-reasoner model pattern", () => {
assert.equal(requiresReasoningReplay("unknown-provider", "deepseek-reasoner"), true);
});
it("should detect kimi-k2 model pattern", () => {
assert.equal(requiresReasoningReplay("unknown-provider", "kimi-k2.5"), true);
});
it("should detect qwq model pattern", () => {
assert.equal(requiresReasoningReplay("unknown-provider", "qwq-32b-preview"), true);
});
it("should detect qwen-thinking model pattern", () => {
assert.equal(requiresReasoningReplay("unknown-provider", "qwen3-thinking-235b"), true);
});
it("should NOT detect a generic openai model", () => {
assert.equal(requiresReasoningReplay("openai", "gpt-4o"), false);
});
it("should NOT detect claude as requiring replay", () => {
assert.equal(requiresReasoningReplay("anthropic", "claude-opus-4"), false);
});
});