From 25864ee540b95e2f97b4e180f5d4823701532eca Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Fri, 15 May 2026 21:30:58 +0800 Subject: [PATCH] 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 --- src/agents/openai-transport-stream.test.ts | 54 ++++++++++++++++++++++ src/agents/openai-transport-stream.ts | 10 +++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 1a625057072..cbb47372a4e 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -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[]; + 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( diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 9037d012a29..d465ca9fc2b 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -2448,10 +2448,16 @@ function sanitizeOpenRouterReasoningReplayFields(record: Record 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; }