mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
24
src/app/api/cache/reasoning/route.ts
vendored
24
src/app/api/cache/reasoning/route.ts
vendored
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
40
src/lib/jobs/reasoningCacheCleanupJob.ts
Normal file
40
src/lib/jobs/reasoningCacheCleanupJob.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user