fix(stream): normalize delta.reasoning to reasoning_content in SSE streaming

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
This commit is contained in:
Mikhail Salnikov
2026-03-29 20:51:26 +03:00
parent 5ad2c6abf6
commit 61d7566ca1
3 changed files with 32 additions and 0 deletions

View File

@@ -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) {

View File

@@ -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 <think> 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 <think>
// 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) {

View File

@@ -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) {