fix(transport): strip empty-string reasoning_content from OpenRouter assistant replay

DeepSeek V4 via OpenRouter injects reasoning_content: "" on assistant
messages that contain tool_calls.  The sanitizer only deleted the field
when it was not a string, so empty strings slipped through and were
replayed on follow-up turns.  OpenRouter rejects the field with an
HTTP 500 Internal Server Error instead of a descriptive 4xx, breaking
every subsequent tool-call turn for the session.

Also strip empty-string reasoning for the same reason.

Closes #82150
This commit is contained in:
luyao618
2026-05-15 21:30:58 +08:00
committed by Peter Steinberger
parent 127156a88a
commit 25864ee540
2 changed files with 62 additions and 2 deletions

View File

@@ -5609,6 +5609,60 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
expect(assistant.reasoning_content).toBe("Need to answer politely.");
});
it("strips empty-string reasoning_content from OpenRouter assistant replay", () => {
// DeepSeek V4 via OpenRouter injects reasoning_content: "" on tool-call
// assistant messages. An empty string is technically a "string" but the
// Chat Completions schema does not accept it, and OpenRouter rejects the
// replayed message with HTTP 500.
const params = buildOpenAICompletionsParams(
openRouterModel,
{
systemPrompt: "system",
messages: [
{ role: "user", content: "read config" },
{
role: "assistant",
provider: "openrouter",
api: "openai-completions",
model: "deepseek/deepseek-v4-pro",
stopReason: "toolUse",
timestamp: 0,
content: [
{
type: "thinking",
thinking: "",
thinkingSignature: "reasoning_content",
},
{
type: "toolCall",
id: "call_1",
name: "read_file",
arguments: { path: "config.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read_file",
content: [{ type: "text", text: "{ }" }],
isError: false,
timestamp: 1,
},
{ role: "user", content: "continue" },
],
tools: [],
} as never,
undefined,
) as { messages: unknown };
const msgs = params.messages as Record<string, unknown>[];
const assistantMsgs = msgs.filter((m) => m.role === "assistant");
for (const msg of assistantMsgs) {
expect(msg).not.toHaveProperty("reasoning_content");
}
});
it("preserves OpenRouter array reasoning_details from tool-call signatures", () => {
const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" };
const params = buildOpenAICompletionsParams(

View File

@@ -2448,10 +2448,16 @@ function sanitizeOpenRouterReasoningReplayFields(record: Record<string, unknown>
delete record.reasoning_details;
}
if ("reasoning" in record && typeof record.reasoning !== "string") {
// Strip non-string AND empty-string reasoning fields — empty strings are
// response artifacts that upstream providers (OpenRouter, DeepSeek) reject
// with HTTP 500 when replayed on follow-up turns.
if ("reasoning" in record && (typeof record.reasoning !== "string" || record.reasoning === "")) {
delete record.reasoning;
}
if ("reasoning_content" in record && typeof record.reasoning_content !== "string") {
if (
"reasoning_content" in record &&
(typeof record.reasoning_content !== "string" || record.reasoning_content === "")
) {
delete record.reasoning_content;
}