From 617a648088ecb0bf56ab4f29ddee50607d70bebc Mon Sep 17 00:00:00 2001 From: ViFigueiredo <67883343+ViFigueiredo@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:04:36 -0300 Subject: [PATCH] fix(translator): use non-empty reasoning_content placeholder on cache miss instead of empty string (#3433) Integrated into release/v3.8.17 --- open-sse/translator/index.ts | 12 +++-- src/app/api/system/version/route.ts | 79 ++++++++++++++++------------- src/lib/system/autoUpdate.ts | 23 +++++++-- tests/unit/reasoning-cache.test.ts | 56 ++++++++++++++++++++ 4 files changed, 127 insertions(+), 43 deletions(-) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 65341d5393..0b6501da8c 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -294,7 +294,6 @@ export function translateRequest( thinkingEnabled: hasThinkingConfig(result), supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), interleavedField: resolvedCapabilities?.interleavedField ?? null, - allowLegacyFallback: false, }); if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isDeepSeekReplayTarget(normalizedProvider, normalizedModel); @@ -370,9 +369,14 @@ export function translateRequest( } } - // Legacy fallback — empty string (works for older DeepSeek versions) - if (hasToolCalls && msg.reasoning_content === undefined) { - msg.reasoning_content = ""; + // Cache miss fallback — use a non-empty placeholder. + // Empty string causes DeepSeek V4+ to reject with 400: + // "reasoning_content in the thinking mode must be passed back to the API." + // Note: injectEmptyReasoningContentForToolCalls may have pre-set + // reasoning_content="" before the cache lookup, so we check for + // both undefined AND empty string here. + if (hasToolCalls && !msg.reasoning_content) { + msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; } } } else if ( diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 94e4cda28b..637b1d34ec 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -14,6 +14,7 @@ import { getAutoUpdateConfig, launchAutoUpdate, validateAutoUpdateRuntime, + PROJECT_ROOT, } from "@/lib/system/autoUpdate"; import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; @@ -169,7 +170,7 @@ export async function POST(req: NextRequest) { }); await execFileAsync("git", ["fetch", "--tags", config.gitRemote], { timeout: 60_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "install", status: "done", message: "Tags fetched" }); @@ -178,7 +179,7 @@ export async function POST(req: NextRequest) { status: "running", message: `Validating ${resolvedTargetTag}...`, }); - await ensureGitTagExists(resolvedTargetTag, execFileAsync, process.cwd()); + await ensureGitTagExists(resolvedTargetTag, execFileAsync, PROJECT_ROOT); send({ step: "install", status: "done", @@ -193,7 +194,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("git", ["stash", "--include-untracked"], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // No local changes to stash. @@ -202,7 +203,7 @@ export async function POST(req: NextRequest) { const shortHead = ( await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { timeout: 10_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }) ).stdout.trim(); const backupBranch = `pre-update/${shortHead}-${new Date().toISOString().replace(/[:.]/g, "-")}`; @@ -210,7 +211,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("git", ["branch", backupBranch], { timeout: 10_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // Backup branch is best-effort only. @@ -218,7 +219,7 @@ export async function POST(req: NextRequest) { await execFileAsync("git", ["checkout", resolvedTargetTag], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "install", status: "done", message: `Checked out ${resolvedTargetTag}` }); @@ -229,14 +230,14 @@ export async function POST(req: NextRequest) { }); await execFileAsync("npm", ["install", "--legacy-peer-deps"], { timeout: 300_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "rebuild", status: "done", message: "Dependencies installed" }); try { await execFileAsync("node", ["scripts/dev/sync-env.mjs"], { timeout: 15_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // .env sync is non-fatal during update. @@ -249,7 +250,7 @@ export async function POST(req: NextRequest) { }); await execFileAsync("npm", ["run", "build"], { timeout: 600_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "rebuild", status: "done", message: "Build complete" }); @@ -257,7 +258,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "restart", status: "done", message: "Service restarted" }); } catch { @@ -306,13 +307,14 @@ export async function POST(req: NextRequest) { try { // Step 1: Install send({ step: "install", status: "running", message: `Installing omniroute@${latest}...` }); - await execFileAsync( - "npm", - ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], - { - timeout: 300000, - } - ); + await execFileAsync( + "npm", + ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], + { + timeout: 300000, + cwd: PROJECT_ROOT, + } + ); send({ step: "install", status: "done", message: `Installed omniroute@${latest}` }); // Step 2: Rebuild native modules (critical for better-sqlite3) @@ -321,29 +323,36 @@ export async function POST(req: NextRequest) { status: "running", message: "Rebuilding native modules (better-sqlite3)...", }); - const globalRoot = ( - await execFileAsync("npm", ["root", "-g"], { timeout: 10000 }) - ).stdout.trim(); + const globalRoot = ( + await execFileAsync("npm", ["root", "-g"], { timeout: 10000, cwd: PROJECT_ROOT }) + ).stdout.trim(); const omniPath = `${globalRoot}/omniroute/app`; - await execFileAsync("npm", ["rebuild", "better-sqlite3"], { - cwd: omniPath, - timeout: 120000, - }); + await execFileAsync( + "npm", + ["rebuild", "better-sqlite3"], + { + cwd: omniPath, + timeout: 120000, + } + ); send({ step: "rebuild", status: "done", message: "Native modules rebuilt" }); // Step 3: Restart PM2 send({ step: "restart", status: "running", message: "Restarting service via PM2..." }); - try { - await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { timeout: 30000 }); - send({ step: "restart", status: "done", message: "Service restarted" }); - } catch { - // PM2 may not be available (Docker/manual setups) - send({ - step: "restart", - status: "skipped", - message: "PM2 not available — manual restart needed", - }); - } + try { + await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { + timeout: 30000, + cwd: PROJECT_ROOT, + }); + send({ step: "restart", status: "done", message: "Service restarted" }); + } catch { + // PM2 may not be available (Docker/manual setups) + send({ + step: "restart", + status: "skipped", + message: "PM2 not available — manual restart needed", + }); + } send({ step: "complete", diff --git a/src/lib/system/autoUpdate.ts b/src/lib/system/autoUpdate.ts index bfa51c648b..c3dad1ed44 100644 --- a/src/lib/system/autoUpdate.ts +++ b/src/lib/system/autoUpdate.ts @@ -3,9 +3,24 @@ import { closeSync, mkdirSync, openSync, existsSync } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { homedir } from "node:os"; const execFileAsync = promisify(execFile); +function resolveProjectRoot(fallback: string): string { + try { + const cwd = process.cwd(); + if (existsSync(path.join(cwd, "package.json"))) return cwd; + if (existsSync(path.join(cwd, ".git"))) return cwd; + return cwd; + } catch { + return fallback; + } +} + +const FALLBACK_CWD = process.env.HOME || homedir() || "/tmp"; +export const PROJECT_ROOT: string = resolveProjectRoot(FALLBACK_CWD); + type ComposeCommand = "docker compose" | "docker-compose"; export type AutoUpdateMode = "npm" | "docker-compose" | "source"; @@ -70,8 +85,8 @@ export function getAutoUpdateConfig(env: NodeJS.ProcessEnv = process.env): AutoU let mode = normalizeMode(env.AUTO_UPDATE_MODE); if (mode === "npm") { - const isGitRepo = existsSync(path.join(process.cwd(), ".git")); - const currentDir = typeof __dirname !== "undefined" ? __dirname : process.cwd(); + const isGitRepo = existsSync(path.join(PROJECT_ROOT, ".git")); + const currentDir = typeof __dirname !== "undefined" ? __dirname : PROJECT_ROOT; const isGlobalNodeModules = currentDir.includes("node_modules"); // If we are not in a global node_modules directory, we are likely a local source install/build. @@ -117,7 +132,7 @@ export async function validateAutoUpdateRuntime( existsImpl: (targetPath: string) => Promise = pathExists ): Promise { if (config.mode === "source") { - const gitDir = path.join(process.cwd(), ".git"); + const gitDir = path.join(PROJECT_ROOT, ".git"); if (!(await existsImpl(gitDir))) { return { supported: false, @@ -197,7 +212,7 @@ export async function validateAutoUpdateRuntime( export async function ensureGitTagExists( targetTag: string, execFileImpl: ExecFileLike = execFileAsync, - cwd = process.cwd() + cwd = PROJECT_ROOT ): Promise { try { await execFileImpl("git", ["rev-parse", "-q", "--verify", `refs/tags/${targetTag}`], { diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index e5c4c7e311..9731ac3428 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -754,6 +754,62 @@ describe("Reasoning Replay Cache — Translator Replay", () => { assert.equal(translated.messages[1].reasoning_content, undefined); }); + + it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => { + // Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets + // reasoning_content="" before the cache lookup. The old condition + // `msg.reasoning_content === undefined` never fired on cache miss, leaving the + // empty string in place. DeepSeek V4+ rejects "" with a 400. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-flash": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = await import( + "../../open-sse/translator/helpers/claudeHelper.ts" + ); + + // No cache entry → cache miss + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-flash", + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + reasoning_content: "", + tool_calls: [ + { + id: "call_empty_rc", + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_empty_rc", content: "file contents" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal( + translated.messages[1].reasoning_content, + NON_ANTHROPIC_THINKING_PLACEHOLDER, + "empty reasoning_content should be replaced with placeholder on cache miss" + ); + }); }); describe("Reasoning Replay Cache — API Route", () => {