From 61d7566ca1b1fb53bf1688f6e400c61cf2eb8f4a Mon Sep 17 00:00:00 2001 From: Mikhail Salnikov <14850941+mikhailsal@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:51:26 +0300 Subject: [PATCH] fix(stream): normalize delta.reasoning to reasoning_content in SSE streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NVIDIA kimi-k2.5 (and potentially other providers) send reasoning tokens as `delta.reasoning` in SSE streaming chunks instead of the standard OpenAI `delta.reasoning_content` field. This caused reasoning content to be silently dropped during stream passthrough — clients received only the final answer with no reasoning separation. The non-streaming sanitizer (responseSanitizer.ts) already handled this alias, but the streaming pipeline did not. Fix applied in 4 locations: - stream.ts passthrough: normalize + force re-serialize sanitized chunk - stream.ts translate: accumulate reasoning from delta.reasoning - sseParser.ts: collect delta.reasoning in parseSSEToOpenAIResponse - streamPayloadCollector.ts: collect delta.reasoning in buildOpenAISummary --- open-sse/handlers/sseParser.ts | 4 ++++ open-sse/utils/stream.ts | 24 ++++++++++++++++++++++++ open-sse/utils/streamPayloadCollector.ts | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index e1e6136127..4a9626ac7b 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -52,6 +52,10 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { reasoningParts.push(delta.reasoning_content); } + // Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.) + if (typeof delta.reasoning === "string" && delta.reasoning.length > 0 && !delta.reasoning_content) { + reasoningParts.push(delta.reasoning); + } // T18: Accumulate tool calls correctly across streamed chunks if (delta.tool_calls) { diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 4c30fe3782..6c274663d7 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -314,6 +314,12 @@ export function createSSEStream(options: StreamOptions = {}) { const delta = parsed.choices?.[0]?.delta; + // Normalize `reasoning` alias → `reasoning_content` (NVIDIA kimi-k2.5 etc.) + if (delta?.reasoning && typeof delta.reasoning === "string" && !delta.reasoning_content) { + delta.reasoning_content = delta.reasoning; + delete delta.reasoning; + } + // Extract tags from streaming content if (delta?.content && typeof delta.content === "string") { const { content, thinking } = extractThinkingFromContent(delta.content); @@ -323,6 +329,14 @@ export function createSSEStream(options: StreamOptions = {}) { } } + // If reasoning was normalized (reasoning → reasoning_content) or + // tags were extracted, force re-serialization so the client sees + // the standard `reasoning_content` field instead of the raw provider line. + if (delta?.reasoning_content && !injectedUsage) { + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } + // T18: Track if we saw tool calls & accumulate for call log if (delta?.tool_calls && delta.tool_calls.length > 0) { passthroughHasToolCalls = true; @@ -483,6 +497,16 @@ export function createSSEStream(options: StreamOptions = {}) { if (state?.accumulatedContent !== undefined) state.accumulatedContent += r; } } + // Normalize `reasoning` alias → `reasoning_content` (NVIDIA kimi-k2.5 etc.) + if (parsed.choices?.[0]?.delta?.reasoning && !parsed.choices?.[0]?.delta?.reasoning_content) { + const r = parsed.choices[0].delta.reasoning; + if (typeof r === "string") { + parsed.choices[0].delta.reasoning_content = r; + delete parsed.choices[0].delta.reasoning; + totalContentLength += r.length; + if (state?.accumulatedContent !== undefined) state.accumulatedContent += r; + } + } // Gemini format - may have multiple parts if (parsed.candidates?.[0]?.content?.parts) { diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index 961dfc46ba..427770fa08 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -157,6 +157,10 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { reasoningParts.push(delta.reasoning_content); } + // Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.) + if (typeof delta.reasoning === "string" && delta.reasoning.length > 0 && !delta.reasoning_content) { + reasoningParts.push(delta.reasoning); + } if (Array.isArray(delta.tool_calls)) { for (const item of delta.tool_calls) {