From f00e9a1272f99114ec7412e94f44a3d2c8ce38ee Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 26 Apr 2026 20:15:47 -0300 Subject: [PATCH] fix(postinstall): extend native module repair to cover wreq-js for pnpm global installs (#1634) - Add fixWreqJsBinary() to postinstall.mjs with 3-strategy repair: 1. Copy platform binary from root node_modules 2. Copy entire rust/ directory (all platform binaries) 3. npm rebuild wreq-js fallback - Fixes macOS arm64 global pnpm installs shipping only Linux binaries - Adds reasoning replay cache feature for DeepSeek V4 (#1628) - Updates CHANGELOG.md with both fixes --- CHANGELOG.md | 2 + open-sse/handlers/chatCore.ts | 29 +- open-sse/services/reasoningCache.ts | 118 ++++-- open-sse/translator/index.ts | 37 +- scripts/postinstall.mjs | 109 +++++- src/app/api/cache/reasoning/route.ts | 24 +- .../migrations/032_create_reasoning_cache.sql | 2 +- src/lib/db/reasoningCache.ts | 57 ++- src/lib/jobs/reasoningCacheCleanupJob.ts | 40 +++ src/server-init.ts | 2 + tests/unit/reasoning-cache.test.ts | 338 ++++++++++++++++++ 11 files changed, 653 insertions(+), 105 deletions(-) create mode 100644 src/lib/jobs/reasoningCacheCleanupJob.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e13814c63b..ba4d707134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - **fix(docker):** Set `NPM_CONFIG_LEGACY_PEER_DEPS=true` in the Docker builder layer before `npm ci` and remove duplicate `postinstallSupport.mjs` COPY instruction — fixes container image build failures introduced in v3.7.0 (#1630 — thanks @rdself). - **fix(antigravity):** Hide deprecated Gemini-routed Claude 4.5 models from public catalogs and model lists. Legacy `gemini-claude-*` aliases now silently resolve to current Claude 4.6 equivalents. Replace dynamic reverse-alias generation with an explicit allowlist for predictable model visibility (#1631 — thanks @backryun). - **fix(types):** Add explicit type annotations to sync-env test helpers and dynamic import casts to satisfy `typecheck:noimplicit:core` CI gate. +- **fix(reasoning):** Implement Reasoning Replay Cache — hybrid memory/SQLite persistence for `reasoning_content` in multi-turn tool-calling flows. Automatically captures reasoning from DeepSeek V4, Kimi K2, Qwen-Thinking, and GLM models and re-injects it on follow-up turns to prevent HTTP 400 errors from strict reasoning-content validation. Includes dashboard telemetry tab, REST API, and 21 unit tests (#1628 — thanks @JasonLandbridge). +- **fix(postinstall):** Extend postinstall native module repair to cover `wreq-js` — detects missing platform-specific `.node` binaries inside `app/node_modules/wreq-js/rust/` and copies them from the root install. Fixes global `pnpm` installs on macOS arm64 where the standalone app directory only contained Linux binaries (#1634 — thanks @MarcosT96). ### 📝 Documentation diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 493387af45..096dd0b340 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -70,6 +70,7 @@ import { } from "../utils/cacheControlPolicy.ts"; import { getCacheMetrics } from "@/lib/db/settings.ts"; import { getCachedSettings } from "@/lib/db/readCache"; +import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts"; import { parseCodexQuotaHeaders, @@ -3018,19 +3019,7 @@ export async function handleChatCore({ 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); - } - } + cacheReasoningFromAssistantMessage(msg, provider, model); } catch { // Cache capture is non-critical — never block the response } @@ -3267,19 +3256,7 @@ export async function handleChatCore({ const body = streamResponseBody as Record; const choices = body.choices as { message?: Record }[] | 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); - } - } + cacheReasoningFromAssistantMessage(msg, provider, model); } catch { // Cache capture is non-critical — never block the stream } diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index 4d7d10f857..2b5f7788e3 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -7,12 +7,22 @@ * "The reasoning_content in the thinking mode must be passed back to the API." * * Architecture: - * Write: memory + DB simultaneously (fire-and-forget DB write) + * Write: memory + DB in the same process * Read: memory first → DB fallback → miss * * @see Issue #1628 */ +import { + clearAllReasoningCache, + cleanupExpiredReasoning, + deleteReasoningCache, + getReasoningCache, + getReasoningCacheEntries, + getReasoningCacheStats, + setReasoningCache, +} from "../../src/lib/db/reasoningCache.ts"; + // ──────────────── Provider/Model Detection ──────────────── const REASONING_REPLAY_PROVIDERS = new Set([ @@ -40,8 +50,10 @@ const REASONING_REPLAY_MODEL_PATTERNS = [ * 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)); + const normalizedProvider = provider.trim().toLowerCase(); + const normalizedModel = model.trim(); + if (REASONING_REPLAY_PROVIDERS.has(normalizedProvider)) return true; + return REASONING_REPLAY_MODEL_PATTERNS.some((p) => p.test(normalizedModel)); } // ──────────────── In-Memory Cache ──────────────── @@ -54,6 +66,17 @@ interface MemoryCacheEntry { createdAt: number; } +type AssistantMessageLike = { + role?: unknown; + tool_calls?: unknown; + reasoning_content?: unknown; + reasoning?: unknown; +}; + +type ToolCallLike = { + id?: unknown; +}; + const memoryCache = new Map(); const MAX_MEMORY_ENTRIES = 2000; const TTL_MS = 2 * 60 * 60 * 1000; // 2 hours @@ -95,7 +118,7 @@ function purgeExpiredMemory(): void { /** * Cache a reasoning_content string for one or more tool_call IDs. - * Writes to both memory and DB (DB write is fire-and-forget). + * Writes to memory and best-effort DB persistence. */ export function cacheReasoning( toolCallId: string, @@ -119,12 +142,10 @@ export function cacheReasoning( 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 + // DB persistence failure is non-fatal; memory cache still serves the hot path. } } @@ -142,6 +163,36 @@ export function cacheReasoningBatch( } } +/** + * Capture reasoning_content from an assistant message with tool calls. + * Returns the number of tool_call IDs cached. + */ +export function cacheReasoningFromAssistantMessage( + message: AssistantMessageLike | null | undefined, + provider: string, + model: string +): number { + if (!message || message.role !== "assistant" || !Array.isArray(message.tool_calls)) { + return 0; + } + + const reasoning = + typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 + ? message.reasoning_content + : typeof message.reasoning === "string" && message.reasoning.length > 0 + ? message.reasoning + : ""; + if (!reasoning) return 0; + + const toolCallIds = (message.tool_calls as ToolCallLike[]) + .map((toolCall) => (typeof toolCall.id === "string" ? toolCall.id : "")) + .filter((id) => id.length > 0); + if (toolCallIds.length === 0) return 0; + + cacheReasoningBatch(toolCallIds, provider, model, reasoning); + return toolCallIds.length; +} + /** * Look up cached reasoning_content by tool_call_id. * Memory first → DB fallback → null (miss). @@ -164,23 +215,23 @@ export function lookupReasoning(toolCallId: string): string | null { } // 2. Fallback to DB + let dbResult: { reasoning: string; provider: string; model: string } | null = null; 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; - } + dbResult = getReasoningCache(toolCallId); } catch { - // DB read failure is non-fatal + // DB lookup failure is non-fatal; treat it as a cache miss. + } + 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; } // 3. Miss @@ -225,12 +276,10 @@ export function getReasoningCacheServiceStats(): { 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 + // DB stats are unavailable; return memory counters with empty persisted stats. } const totalLookups = hits + misses; @@ -264,7 +313,6 @@ export function getReasoningCacheServiceEntries( } = {} ): unknown[] { try { - const { getReasoningCacheEntries } = require("@/lib/db/reasoningCache"); return getReasoningCacheEntries(opts); } catch { return []; @@ -290,15 +338,28 @@ export function clearReasoningCacheAll(provider?: string): number { misses = 0; replays = 0; - // Clear DB try { - const { clearAllReasoningCache } = require("@/lib/db/reasoningCache"); return clearAllReasoningCache(provider); } catch { return 0; } } +/** + * Delete one reasoning cache entry by tool_call_id from memory + DB. + */ +export function deleteReasoningCacheEntry(toolCallId: string): number { + if (!toolCallId) return 0; + const existedInMemory = memoryCache.delete(toolCallId); + let deletedFromDb = 0; + try { + deletedFromDb = deleteReasoningCache(toolCallId); + } catch { + // Memory delete already happened; DB delete can be retried by a later cleanup. + } + return deletedFromDb + (existedInMemory && deletedFromDb === 0 ? 1 : 0); +} + /** * Cleanup expired entries from both memory and DB. * Called periodically (e.g., every 30 min from health-check). @@ -306,7 +367,6 @@ export function clearReasoningCacheAll(provider?: string): number { export function cleanupReasoningCache(): number { purgeExpiredMemory(); try { - const { cleanupExpiredReasoning } = require("@/lib/db/reasoningCache"); return cleanupExpiredReasoning(); } catch { return 0; diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 57649cc8c2..41f434fa06 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -12,6 +12,11 @@ import { bootstrapTranslatorRegistry } from "./bootstrap.ts"; import { normalizeThinkingConfig } from "../services/provider.ts"; import { applyThinkingBudget } from "../services/thinkingBudget.ts"; import { normalizeRoles } from "../services/roleNormalizer.ts"; +import { + lookupReasoning, + recordReplay, + requiresReasoningReplay, +} from "../services/reasoningCache.ts"; bootstrapTranslatorRegistry(); export { register } from "./registry.ts"; @@ -209,22 +214,8 @@ export function translateRequest( // 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)); + const isReasoner = requiresReasoningReplay(String(provider ?? ""), String(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) { // Skip if client already provided real reasoning_content @@ -233,15 +224,13 @@ export function translateRequest( } // 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; - } + const firstToolId = msg.tool_calls[0]?.id; + if (firstToolId) { + const cached = lookupReasoning(firstToolId); + if (cached) { + msg.reasoning_content = cached; + recordReplay(); + continue; } } diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 87cd21a659..a9ef2689df 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -4,20 +4,25 @@ * OmniRoute — Postinstall Native Module Fix * * The npm package ships with a Next.js standalone build that includes - * better-sqlite3 compiled for the build platform (Linux x64) inside - * app/node_modules/. However, npm also installs better-sqlite3 as a - * top-level dependency (in the root node_modules/), correctly compiled - * for the user's platform. + * native modules compiled for the build platform (Linux x64) inside + * app/node_modules/. However, npm also installs these as top-level + * dependencies (in the root node_modules/), correctly compiled for + * the user's platform. * - * This script copies the correctly-built native binary from the root + * This script copies the correctly-built native binaries from the root * into the standalone app directory — no rebuild or build tools needed. * + * Modules repaired: + * - better-sqlite3 (SQLite bindings) + * - wreq-js (TLS client for OAuth providers) + * * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426 + * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634 */ -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -174,6 +179,97 @@ async function fixBetterSqliteBinary() { console.warn(""); } +/** + * Fix wreq-js native binary for the standalone app directory. + * + * wreq-js ships platform-specific .node binaries under rust/. + * The standalone build may only contain Linux binaries from the CI. + * This copies the correct platform binary from the root install. + * + * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634 + */ +async function fixWreqJsBinary() { + const appWreqDir = join(ROOT, "app", "node_modules", "wreq-js", "rust"); + const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust"); + + if (!existsSync(join(ROOT, "app", "node_modules", "wreq-js"))) { + return; + } + + const binaryName = `wreq-js.${process.platform}-${process.arch}.node`; + const appBinaryPath = join(appWreqDir, binaryName); + const rootBinaryPath = join(rootWreqDir, binaryName); + + // Check if the platform binary already exists and loads + if (existsSync(appBinaryPath)) { + try { + process.dlopen({ exports: {} }, appBinaryPath); + return; // Already working + } catch (err) { + console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`); + } + } + + console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`); + + // Strategy 1: Copy from root node_modules + if (existsSync(rootBinaryPath)) { + try { + mkdirSync(appWreqDir, { recursive: true }); + copyFileSync(rootBinaryPath, appBinaryPath); + process.dlopen({ exports: {} }, appBinaryPath); + console.log(" ✅ wreq-js native module fixed successfully!\n"); + return; + } catch (err) { + console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`); + } + } + + // Strategy 2: Copy entire rust/ directory from root (gets all platform binaries) + if (existsSync(rootWreqDir)) { + try { + mkdirSync(appWreqDir, { recursive: true }); + const files = readdirSync(rootWreqDir); + for (const file of files) { + if (file.endsWith(".node")) { + copyFileSync(join(rootWreqDir, file), join(appWreqDir, file)); + } + } + if (existsSync(appBinaryPath)) { + process.dlopen({ exports: {} }, appBinaryPath); + console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n"); + return; + } + } catch (err) { + console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`); + } + } + + // Strategy 3: Rebuild wreq-js inside app/ + console.log(" 📥 Attempting npm rebuild wreq-js..."); + try { + const { execSync } = await import("node:child_process"); + execSync("npm rebuild wreq-js", { + cwd: join(ROOT, "app"), + stdio: "inherit", + timeout: 120_000, + }); + if (existsSync(appBinaryPath)) { + process.dlopen({ exports: {} }, appBinaryPath); + console.log(" ✅ wreq-js native module rebuilt successfully!\n"); + return; + } + } catch (err) { + console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`); + } + + console.warn( + `\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.` + ); + console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work."); + console.warn(` Manual fix: cd ${join(ROOT, "app")} && npm install wreq-js --no-save\n`); +} + async function ensureSwcHelpers() { if (!hasStandaloneAppBundle(ROOT)) { return; @@ -215,5 +311,6 @@ async function syncProjectEnv() { } await fixBetterSqliteBinary(); +await fixWreqJsBinary(); await ensureSwcHelpers(); await syncProjectEnv(); diff --git a/src/app/api/cache/reasoning/route.ts b/src/app/api/cache/reasoning/route.ts index e5d144fa47..c2ec8cf854 100644 --- a/src/app/api/cache/reasoning/route.ts +++ b/src/app/api/cache/reasoning/route.ts @@ -1,5 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + clearReasoningCacheAll, + deleteReasoningCacheEntry, + getReasoningCacheServiceEntries, + getReasoningCacheServiceStats, +} from "@omniroute/open-sse/services/reasoningCache.ts"; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -17,9 +23,6 @@ export async function GET(req: NextRequest) { } 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; @@ -44,7 +47,7 @@ export async function GET(req: NextRequest) { * DELETE /api/cache/reasoning * * Clears reasoning cache entries. - * Query params: ?provider=deepseek (filter by provider) or no params (clear all). + * Query params: ?toolCallId=call_abc (single entry), ?provider=deepseek, or no params. */ export async function DELETE(req: NextRequest) { if (!(await isAuthenticated(req))) { @@ -52,11 +55,20 @@ export async function DELETE(req: NextRequest) { } try { - const { clearReasoningCacheAll } = await import("@omniroute/open-sse/services/reasoningCache"); - const { searchParams } = new URL(req.url); + const toolCallId = searchParams.get("toolCallId") || undefined; const provider = searchParams.get("provider") || undefined; + if (toolCallId) { + const cleared = deleteReasoningCacheEntry(toolCallId); + return NextResponse.json({ + ok: true, + cleared, + scope: "toolCallId", + toolCallId, + }); + } + const cleared = clearReasoningCacheAll(provider); return NextResponse.json({ diff --git a/src/lib/db/migrations/032_create_reasoning_cache.sql b/src/lib/db/migrations/032_create_reasoning_cache.sql index 1d1efa1eb5..c2937d875e 100644 --- a/src/lib/db/migrations/032_create_reasoning_cache.sql +++ b/src/lib/db/migrations/032_create_reasoning_cache.sql @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS reasoning_cache ( reasoning TEXT NOT NULL, char_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT NOT NULL + expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_reasoning_cache_expires diff --git a/src/lib/db/reasoningCache.ts b/src/lib/db/reasoningCache.ts index 146b3bef68..f2234af7c4 100644 --- a/src/lib/db/reasoningCache.ts +++ b/src/lib/db/reasoningCache.ts @@ -36,6 +36,36 @@ export interface ReasoningCacheStats { const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours +function toUnixEpochSeconds(dateMs: number): number { + return Math.floor(dateMs / 1000); +} + +const EXPIRES_AT_EPOCH_SQL = `COALESCE( + CASE + WHEN typeof(expires_at) IN ('integer', 'real') THEN CAST(expires_at AS INTEGER) + WHEN typeof(expires_at) = 'text' AND expires_at <> '' AND expires_at NOT GLOB '*[^0-9]*' + THEN CAST(expires_at AS INTEGER) + ELSE unixepoch(expires_at) + END, + 0 +)`; + +function epochSecondsToIso(value: number | string): string { + const text = String(value); + const seconds = + typeof value === "number" || (text !== "" && !/[^0-9]/.test(text)) + ? Number.parseInt(text, 10) + : NaN; + if (Number.isFinite(seconds) && seconds > 0) { + return new Date(seconds * 1000).toISOString(); + } + const parsedMs = Date.parse(text); + if (Number.isFinite(parsedMs)) { + return new Date(parsedMs).toISOString(); + } + return String(value); +} + // ──────────────── CRUD ──────────────── /** @@ -50,7 +80,7 @@ export function setReasoningCache( ttlMs: number = DEFAULT_TTL_MS ): void { const db = getDbInstance(); - const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + const expiresAt = toUnixEpochSeconds(Date.now() + ttlMs); const charCount = reasoning.length; db.prepare( @@ -71,7 +101,7 @@ export function getReasoningCache( const row = db .prepare( `SELECT reasoning, provider, model FROM reasoning_cache - WHERE tool_call_id = ? AND expires_at > datetime('now')` + WHERE tool_call_id = ? AND ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')` ) .get(toolCallId) as { reasoning: string; provider: string; model: string } | undefined; @@ -81,9 +111,10 @@ export function getReasoningCache( /** * Delete a specific reasoning cache entry. */ -export function deleteReasoningCache(toolCallId: string): void { +export function deleteReasoningCache(toolCallId: string): number { const db = getDbInstance(); - db.prepare(`DELETE FROM reasoning_cache WHERE tool_call_id = ?`).run(toolCallId); + const result = db.prepare(`DELETE FROM reasoning_cache WHERE tool_call_id = ?`).run(toolCallId); + return result.changes; } /** @@ -92,7 +123,7 @@ export function deleteReasoningCache(toolCallId: string): void { export function cleanupExpiredReasoning(): number { const db = getDbInstance(); const result = db - .prepare(`DELETE FROM reasoning_cache WHERE expires_at <= datetime('now')`) + .prepare(`DELETE FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} <= unixepoch('now')`) .run(); return result.changes; } @@ -123,7 +154,7 @@ export function getReasoningCacheStats(): ReasoningCacheStats { 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')` + FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')` ) .get() as { total_entries: number; total_chars: number }; @@ -131,7 +162,7 @@ export function getReasoningCacheStats(): ReasoningCacheStats { const providerRows = db .prepare( `SELECT provider, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars - FROM reasoning_cache WHERE expires_at > datetime('now') + FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now') GROUP BY provider ORDER BY entries DESC` ) .all() as { provider: string; entries: number; chars: number }[]; @@ -145,7 +176,7 @@ export function getReasoningCacheStats(): ReasoningCacheStats { const modelRows = db .prepare( `SELECT model, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars - FROM reasoning_cache WHERE expires_at > datetime('now') + FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now') GROUP BY model ORDER BY entries DESC` ) .all() as { model: string; entries: number; chars: number }[]; @@ -159,14 +190,14 @@ export function getReasoningCacheStats(): ReasoningCacheStats { const oldest = db .prepare( `SELECT created_at FROM reasoning_cache - WHERE expires_at > datetime('now') ORDER BY created_at ASC LIMIT 1` + WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('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` + WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now') ORDER BY created_at DESC LIMIT 1` ) .get() as { created_at: string } | undefined; @@ -197,7 +228,7 @@ export function getReasoningCacheEntries( const limit = Math.min(opts.limit ?? 50, 200); const offset = opts.offset ?? 0; - const conditions: string[] = ["expires_at > datetime('now')"]; + const conditions: string[] = [`${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')`]; const params: unknown[] = []; if (opts.provider) { @@ -225,7 +256,7 @@ export function getReasoningCacheEntries( reasoning: string; char_count: number; created_at: string; - expires_at: string; + expires_at: number | string; }[]; return rows.map((row) => ({ @@ -235,6 +266,6 @@ export function getReasoningCacheEntries( reasoning: row.reasoning, charCount: row.char_count, createdAt: row.created_at, - expiresAt: row.expires_at, + expiresAt: epochSecondsToIso(row.expires_at), })); } diff --git a/src/lib/jobs/reasoningCacheCleanupJob.ts b/src/lib/jobs/reasoningCacheCleanupJob.ts new file mode 100644 index 0000000000..5aa892a3eb --- /dev/null +++ b/src/lib/jobs/reasoningCacheCleanupJob.ts @@ -0,0 +1,40 @@ +import { cleanupReasoningCache } from "../../../open-sse/services/reasoningCache.ts"; + +const DEFAULT_INTERVAL_MS = 30 * 60 * 1000; + +let timer: NodeJS.Timeout | null = null; + +function getIntervalMs() { + const raw = process.env.OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS; + const parsed = raw ? Number(raw) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 60_000 ? parsed : DEFAULT_INTERVAL_MS; +} + +export function startReasoningCacheCleanupJob() { + if (timer) { + return timer; + } + + const run = () => { + try { + const deleted = cleanupReasoningCache(); + if (deleted > 0) { + console.log(`[ReasoningCache] expired entries removed=${deleted}`); + } + } catch (error) { + console.error("[ReasoningCache] Cleanup job failed:", error); + } + }; + + run(); + timer = setInterval(run, getIntervalMs()); + timer.unref?.(); + return timer; +} + +export function stopReasoningCacheCleanupJob() { + if (timer) { + clearInterval(timer); + timer = null; + } +} diff --git a/src/server-init.ts b/src/server-init.ts index d13679cda8..9a94afaf61 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -5,6 +5,7 @@ import { enforceSecrets } from "./shared/utils/secretsValidator"; import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/compliance/index"; import { initConsoleInterceptor } from "./lib/consoleInterceptor"; import { startBudgetResetJob } from "./lib/jobs/budgetResetJob"; +import { startReasoningCacheCleanupJob } from "./lib/jobs/reasoningCacheCleanupJob"; import { getSettings } from "./lib/db/settings"; import { applyRuntimeSettings } from "./lib/config/runtimeSettings"; import { startRuntimeConfigHotReload } from "./lib/config/hotReload"; @@ -84,6 +85,7 @@ async function startServer() { startupLog.info("Builtin skill handlers registered"); await initializeCloudSync(); startBudgetResetJob(); + startReasoningCacheCleanupJob(); startRuntimeConfigHotReload(); startupLog.info("Server started with cloud sync initialized"); diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index 6d2cd97148..c72291bc1d 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -11,8 +11,11 @@ import assert from "node:assert/strict"; // ──────────── Direct service import ──────────── import { + cacheReasoningFromAssistantMessage, cacheReasoning, cacheReasoningBatch, + deleteReasoningCacheEntry, + getReasoningCacheServiceEntries, lookupReasoning, recordReplay, getReasoningCacheServiceStats, @@ -20,6 +23,11 @@ import { requiresReasoningReplay, cleanupReasoningCache, } from "../../open-sse/services/reasoningCache.ts"; +import { translateRequest } from "../../open-sse/translator/index.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { getReasoningCache, setReasoningCache } from "../../src/lib/db/reasoningCache.ts"; +import { DELETE, GET } from "../../src/app/api/cache/reasoning/route.ts"; describe("Reasoning Replay Cache — Service Layer", () => { before(() => { @@ -40,6 +48,19 @@ describe("Reasoning Replay Cache — Service Layer", () => { ); const result = lookupReasoning("call_test_1"); assert.equal(result, "The user wants to read the file..."); + assert.equal(getReasoningCache("call_test_1")?.reasoning, "The user wants to read the file..."); + }); + + it("should fall back to SQLite when memory misses", () => { + clearReasoningCacheAll(); + setReasoningCache("call_db_only", "deepseek", "deepseek-reasoner", "DB-only reasoning"); + + assert.equal(lookupReasoning("call_db_only"), "DB-only reasoning"); + + const stats = getReasoningCacheServiceStats(); + assert.equal(stats.hits, 1); + assert.equal(stats.memoryEntries, 1); + assert.equal(stats.dbEntries, 1); }); it("should return null for unknown tool_call_id", () => { @@ -70,6 +91,41 @@ describe("Reasoning Replay Cache — Service Layer", () => { assert.equal(lookupReasoning("call_batch_3"), "Batch reasoning content"); }); + it("should capture assistant reasoning for all tool_call IDs", () => { + clearReasoningCacheAll(); + + const cached = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: "Captured assistant reasoning", + tool_calls: [{ id: "call_capture_1" }, { id: "call_capture_2" }], + }, + "deepseek", + "deepseek-reasoner" + ); + + assert.equal(cached, 2); + assert.equal(lookupReasoning("call_capture_1"), "Captured assistant reasoning"); + assert.equal(lookupReasoning("call_capture_2"), "Captured assistant reasoning"); + }); + + it("should capture provider reasoning alias when reasoning_content is absent", () => { + clearReasoningCacheAll(); + + const cached = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning: "Alias reasoning", + tool_calls: [{ id: "call_capture_alias" }], + }, + "kimi", + "kimi-k2.5" + ); + + assert.equal(cached, 1); + assert.equal(lookupReasoning("call_capture_alias"), "Alias reasoning"); + }); + 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"); @@ -122,6 +178,25 @@ describe("Reasoning Replay Cache — Service Layer", () => { assert.ok(stats.replayRate.endsWith("%")); assert.equal(typeof stats.byProvider, "object"); assert.equal(typeof stats.byModel, "object"); + assert.equal(stats.dbEntries, 2); + assert.equal(stats.byProvider.deepseek.entries, 1); + assert.equal(stats.byProvider.kimi.entries, 1); + }); + + it("should list persisted entries for the dashboard API", () => { + clearReasoningCacheAll(); + + cacheReasoning("call_entry_1", "deepseek", "deepseek-reasoner", "Entry reasoning A"); + cacheReasoning("call_entry_2", "kimi", "kimi-k2.5", "Entry reasoning B"); + + const deepseekEntries = getReasoningCacheServiceEntries({ provider: "deepseek" }) as Array<{ + toolCallId: string; + expiresAt: string; + }>; + + assert.equal(deepseekEntries.length, 1); + assert.equal(deepseekEntries[0].toolCallId, "call_entry_1"); + assert.doesNotThrow(() => new Date(deepseekEntries[0].expiresAt).toISOString()); }); it("should clear all entries", () => { @@ -135,6 +210,28 @@ describe("Reasoning Replay Cache — Service Layer", () => { assert.equal(lookupReasoning("call_clear_2"), null); }); + it("should delete one entry by tool_call_id", () => { + clearReasoningCacheAll(); + + cacheReasoning("call_delete_1", "deepseek", "deepseek-chat", "Delete me"); + cacheReasoning("call_delete_2", "deepseek", "deepseek-chat", "Keep me"); + + assert.equal(deleteReasoningCacheEntry("call_delete_1"), 1); + assert.equal(lookupReasoning("call_delete_1"), null); + assert.equal(lookupReasoning("call_delete_2"), "Keep me"); + }); + + it("should clear entries by provider only", () => { + clearReasoningCacheAll(); + + cacheReasoning("call_provider_ds", "deepseek", "deepseek-chat", "DeepSeek reasoning"); + cacheReasoning("call_provider_kimi", "kimi", "kimi-k2.5", "Kimi reasoning"); + + assert.equal(clearReasoningCacheAll("deepseek"), 1); + assert.equal(lookupReasoning("call_provider_ds"), null); + assert.equal(lookupReasoning("call_provider_kimi"), "Kimi reasoning"); + }); + it("should cleanup expired reasoning (no-op when nothing expired)", () => { cacheReasoning("call_cleanup_test", "deepseek", "deepseek-chat", "Not expired yet"); const cleaned = cleanupReasoningCache(); @@ -142,6 +239,59 @@ describe("Reasoning Replay Cache — Service Layer", () => { // Entry should still be available since TTL is 2 hours assert.equal(lookupReasoning("call_cleanup_test"), "Not expired yet"); }); + + it("should not return expired SQLite entries and cleanup should prune them", () => { + clearReasoningCacheAll(); + setReasoningCache("call_expired", "deepseek", "deepseek-chat", "Expired reasoning", -1_000); + + assert.equal(lookupReasoning("call_expired"), null); + assert.equal(cleanupReasoningCache(), 1); + assert.equal(getReasoningCacheServiceStats().dbEntries, 0); + }); + + it("should read and prune legacy ISO expires_at rows", () => { + clearReasoningCacheAll(); + + const db = getDbInstance(); + const futureIso = new Date(Date.now() + 60_000).toISOString(); + const expiredIso = new Date(Date.now() - 60_000).toISOString(); + db.prepare( + `INSERT INTO reasoning_cache + (tool_call_id, provider, model, reasoning, char_count, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, datetime('now'), ?)` + ).run( + "call_legacy_iso_active", + "deepseek", + "deepseek-chat", + "Legacy ISO reasoning", + "Legacy ISO reasoning".length, + futureIso + ); + db.prepare( + `INSERT INTO reasoning_cache + (tool_call_id, provider, model, reasoning, char_count, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, datetime('now'), ?)` + ).run( + "call_legacy_iso_expired", + "deepseek", + "deepseek-chat", + "Expired legacy ISO reasoning", + "Expired legacy ISO reasoning".length, + expiredIso + ); + + assert.equal(lookupReasoning("call_legacy_iso_active"), "Legacy ISO reasoning"); + assert.equal(lookupReasoning("call_legacy_iso_expired"), null); + const entries = getReasoningCacheServiceEntries({ provider: "deepseek" }) as Array<{ + toolCallId: string; + expiresAt: string; + }>; + assert.equal( + entries.some((entry) => entry.expiresAt === futureIso), + true + ); + assert.equal(cleanupReasoningCache(), 1); + }); }); describe("Reasoning Replay Cache — Provider Detection", () => { @@ -177,6 +327,10 @@ describe("Reasoning Replay Cache — Provider Detection", () => { assert.equal(requiresReasoningReplay("unknown-provider", "qwen3-thinking-235b"), true); }); + it("should detect GLM thinking model pattern", () => { + assert.equal(requiresReasoningReplay("glm", "glm-5-thinking"), true); + }); + it("should NOT detect a generic openai model", () => { assert.equal(requiresReasoningReplay("openai", "gpt-4o"), false); }); @@ -185,3 +339,187 @@ describe("Reasoning Replay Cache — Provider Detection", () => { assert.equal(requiresReasoningReplay("anthropic", "claude-opus-4"), false); }); }); + +describe("Reasoning Replay Cache — Translator Replay", () => { + before(() => { + clearReasoningCacheAll(); + }); + + after(() => { + clearReasoningCacheAll(); + }); + + function translateWithToolHistory(provider: string, model: string, callId: string) { + return translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + model, + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: callId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: callId, content: "tool result" }, + ], + }, + false, + null, + provider + ); + } + + it("should inject cached reasoning for DeepSeek instead of empty fallback", () => { + clearReasoningCacheAll(); + cacheReasoning("call_translate_ds", "deepseek", "deepseek-reasoner", "DeepSeek cached plan"); + + const translated = translateWithToolHistory( + "deepseek", + "deepseek-reasoner", + "call_translate_ds" + ); + + assert.equal(translated.messages[1].reasoning_content, "DeepSeek cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); + + it("should preserve client-provided reasoning content", () => { + clearReasoningCacheAll(); + cacheReasoning("call_preserve", "deepseek", "deepseek-reasoner", "Cached reasoning"); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-reasoner", + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + reasoning_content: "Client reasoning", + tool_calls: [ + { + id: "call_preserve", + type: "function", + function: { name: "tool", arguments: "{}" }, + }, + ], + }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal(translated.messages[1].reasoning_content, "Client reasoning"); + assert.equal(getReasoningCacheServiceStats().replays, 0); + }); + + it("should inject cached reasoning for Qwen and GLM thinking models", () => { + clearReasoningCacheAll(); + cacheReasoning("call_qwen_think", "qwen", "qwen3-thinking-235b", "Qwen cached plan"); + cacheReasoning("call_glm_think", "glm", "glm-5-thinking", "GLM cached plan"); + + const qwen = translateWithToolHistory("qwen", "qwen3-thinking-235b", "call_qwen_think"); + const glm = translateWithToolHistory("glm", "glm-5-thinking", "call_glm_think"); + + assert.equal(qwen.messages[1].reasoning_content, "Qwen cached plan"); + assert.equal(glm.messages[1].reasoning_content, "GLM cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 2); + }); + + it("should not inject reasoning_content for generic non-reasoning providers", () => { + clearReasoningCacheAll(); + cacheReasoning("call_openai", "openai", "gpt-4o", "Should not replay"); + + const translated = translateWithToolHistory("openai", "gpt-4o", "call_openai"); + + assert.equal(translated.messages[1].reasoning_content, undefined); + assert.equal(getReasoningCacheServiceStats().replays, 0); + }); + + it("should support the full capture then replay flow", () => { + clearReasoningCacheAll(); + + const captured = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: "Full flow cached plan", + tool_calls: [{ id: "call_full_flow", type: "function" }], + }, + "deepseek", + "deepseek-reasoner" + ); + + const translated = translateWithToolHistory("deepseek", "deepseek-reasoner", "call_full_flow"); + + assert.equal(captured, 1); + assert.equal(translated.messages[1].reasoning_content, "Full flow cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); +}); + +describe("Reasoning Replay Cache — API Route", () => { + before(() => { + clearReasoningCacheAll(); + }); + + after(() => { + clearReasoningCacheAll(); + }); + + it("should return stats and entries from GET", async () => { + clearReasoningCacheAll(); + cacheReasoning("call_api_get", "deepseek", "deepseek-reasoner", "API visible reasoning"); + + const response = await GET( + new Request("http://localhost/api/cache/reasoning?provider=deepseek") as never + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.stats.dbEntries, 1); + assert.equal(body.entries.length, 1); + assert.equal(body.entries[0].toolCallId, "call_api_get"); + }); + + it("should delete a single entry by toolCallId", async () => { + clearReasoningCacheAll(); + cacheReasoning("call_api_delete_1", "deepseek", "deepseek-reasoner", "Delete API"); + cacheReasoning("call_api_delete_2", "deepseek", "deepseek-reasoner", "Keep API"); + + const response = await DELETE( + new Request("http://localhost/api/cache/reasoning?toolCallId=call_api_delete_1") as never + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.scope, "toolCallId"); + assert.equal(body.cleared, 1); + assert.equal(lookupReasoning("call_api_delete_1"), null); + assert.equal(lookupReasoning("call_api_delete_2"), "Keep API"); + }); + + it("should delete entries by provider", async () => { + clearReasoningCacheAll(); + cacheReasoning("call_api_provider_ds", "deepseek", "deepseek-reasoner", "Delete provider"); + cacheReasoning("call_api_provider_kimi", "kimi", "kimi-k2.5", "Keep provider"); + + const response = await DELETE( + new Request("http://localhost/api/cache/reasoning?provider=deepseek") as never + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.scope, "provider"); + assert.equal(body.cleared, 1); + assert.equal(lookupReasoning("call_api_provider_ds"), null); + assert.equal(lookupReasoning("call_api_provider_kimi"), "Keep provider"); + }); +});