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
This commit is contained in:
diegosouzapw
2026-04-26 20:15:47 -03:00
parent cabdfb04e7
commit f00e9a1272
11 changed files with 653 additions and 105 deletions

View File

@@ -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

View File

@@ -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<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);
}
}
cacheReasoningFromAssistantMessage(msg, provider, model);
} catch {
// Cache capture is non-critical — never block the stream
}

View File

@@ -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<string, MemoryCacheEntry>();
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;

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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({

View File

@@ -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

View File

@@ -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),
}));
}

View File

@@ -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;
}
}

View File

@@ -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");

View File

@@ -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");
});
});