fix(translator): use non-empty reasoning_content placeholder on cache miss instead of empty string (#3433)

Integrated into release/v3.8.17
This commit is contained in:
ViFigueiredo
2026-06-08 19:04:36 -03:00
committed by GitHub
parent 1322411343
commit 617a648088
4 changed files with 127 additions and 43 deletions

View File

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

View File

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

View File

@@ -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<boolean> = pathExists
): Promise<AutoUpdateValidation> {
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<void> {
try {
await execFileImpl("git", ["rev-parse", "-q", "--verify", `refs/tags/${targetTag}`], {

View File

@@ -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", () => {