From 4e53fba8b9f39fc902e213c58ddd383db5dbc694 Mon Sep 17 00:00:00 2001 From: Davy Massoneto Date: Sun, 10 May 2026 21:25:32 -0300 Subject: [PATCH] fix(sanitizer): preserve reasoning_content on assistant messages with tool_calls (#2140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — preserves reasoning_content on assistant messages with tool_calls/function_call, fixing Kimi 400 errors. --- open-sse/handlers/responseSanitizer.ts | 7 +- tests/unit/response-sanitizer.test.ts | 89 +++++++++++++- .../unit/translator-openai-to-claude.test.ts | 114 ++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 4b85cb0119..cf3131f79e 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -285,7 +285,12 @@ function sanitizeMessage(msg: unknown): unknown { // Non-streaming responses should not expose both visible content and reasoning_content. // Some clients drop the visible assistant text or render duplicated panels when both fields // are present in the final payload. Keep reasoning_content only for reasoning-only messages. - if (sanitized.reasoning_content !== undefined && hasVisibleMessageContent(sanitized.content)) { + if ( + sanitized.reasoning_content !== undefined && + hasVisibleMessageContent(sanitized.content) && + !msgRecord.tool_calls && + !msgRecord.function_call + ) { delete sanitized.reasoning_content; } diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 72a1ced0b6..9cc7b71ab2 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -37,7 +37,7 @@ test("sanitizeOpenAIResponse strips non-standard fields and preserves required t }); }); -test("sanitizeOpenAIResponse extracts thinking, collapses newlines, strips final reasoning_content, and preserves tool calls", () => { +test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves reasoning_content with tool_calls, and preserves tool calls", () => { const sanitized = sanitizeOpenAIResponse({ id: "chatcmpl_test", model: "gpt-4.1", @@ -58,7 +58,7 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, strips final assert.equal((sanitized as any).choices[0].index, 2); assert.equal((sanitized as any).choices[0].finish_reason, "tool_calls"); (assert as any).equal((sanitized as any).choices[0].message.content, "Hello\n\nworld"); - assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); + assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal chain"); (assert as any).deepEqual((sanitized as any).choices[0].message.tool_calls, [{ id: "call_1" }]); assert.deepEqual((sanitized as any).choices[0].message.function_call, { name: "legacy" }); }); @@ -309,6 +309,91 @@ test("sanitizeStreamingChunk preserves Copilot reasoning_text deltas", () => { assert.equal((sanitized as any).choices[0].delta.reasoning_text, "copilot reasoning"); }); +test("sanitizeOpenAIResponse preserves reasoning_content when tool_calls are present", () => { + // Bug fix: Kimi and other thinking-enabled providers require reasoning_content + // on assistant messages that contain tool_calls. The sanitizer was stripping + // reasoning_content whenever visible content existed, breaking subsequent + // requests with "thinking is enabled but reasoning_content is missing". + const sanitized = sanitizeOpenAIResponse({ + model: "kimi-k2.6-thinking", + choices: [ + { + message: { + role: "assistant", + content: "Let me search for that.", + reasoning_content: "I need to use the web search tool to find current information.", + tool_calls: [ + { + id: "call_search_1", + type: "function", + function: { + name: "web_search", + arguments: '{"query":"latest news"}', + }, + }, + ], + }, + }, + ], + }); + + const message = (sanitized as any).choices[0].message; + assert.equal(message.content, "Let me search for that."); + assert.equal( + message.reasoning_content, + "I need to use the web search tool to find current information.", + "reasoning_content must be preserved when tool_calls are present" + ); + assert.equal(message.tool_calls.length, 1); + assert.equal(message.tool_calls[0].id, "call_search_1"); +}); + +test("sanitizeOpenAIResponse still strips reasoning_content when no tool_calls exist", () => { + // When there are no tool_calls, the original behavior should remain: + // reasoning_content is stripped to avoid client rendering issues. + const sanitized = sanitizeOpenAIResponse({ + model: "gpt-4.1", + choices: [ + { + message: { + role: "assistant", + content: "Hello world", + reasoning_content: "Some internal reasoning", + }, + }, + ], + }); + + const message = (sanitized as any).choices[0].message; + assert.equal(message.content, "Hello world"); + assert.equal(message.reasoning_content, undefined); +}); + +test("sanitizeOpenAIResponse preserves reasoning_content when legacy function_call is present", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "kimi-k2.6-thinking", + choices: [ + { + message: { + role: "assistant", + content: "Let me calculate that.", + reasoning_content: "I need to use the calculator function.", + function_call: { name: "calculate", arguments: '{"expr":"1+1"}' }, + }, + }, + ], + }); + + const message = (sanitized as any).choices[0].message; + assert.equal(message.content, "Let me calculate that."); + assert.equal( + message.reasoning_content, + "I need to use the calculator function.", + "reasoning_content must be preserved when legacy function_call is present" + ); + assert.deepEqual(message.function_call, { name: "calculate", arguments: '{"expr":"1+1"}' }); +}); + test("sanitize functions return non-object inputs unchanged", () => { assert.equal(sanitizeOpenAIResponse(null), null); assert.equal(sanitizeStreamingChunk("raw text"), "raw text"); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 8fa862b8d8..2c6b63a0f3 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -348,3 +348,117 @@ test("OpenAI -> Claude can disable OAuth prefixes and Antigravity strips Claude- "read_file" ); }); + +test("OpenAI -> Claude preserves reasoning_content on assistant tool call messages when thinking is enabled", () => { + // Bug: Kimi (and other thinking-enabled providers) require reasoning_content + // on assistant messages that contain tool_calls. When reasoning_content is + // present, it must be converted to a thinking block. When it's missing but + // thinking is enabled, we must NOT drop the tool_calls. + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + reasoning_content: "I need to check the weather", + content: "Let me check that for you.", + tool_calls: [ + { + id: "call_weather_1", + type: "function", + function: { + name: "get_weather", + arguments: '{"location":"Tokyo"}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_weather_1", + content: "Sunny, 25C", + }, + { role: "user", content: "Thanks!" }, + ], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather info", + parameters: { type: "object", properties: {} }, + }, + }, + ], + thinking: { type: "enabled", budget_tokens: 1024 }, + }, + false + ); + + // Find the assistant message with tool_calls + const assistantMsgs = result.messages.filter((m) => m.role === "assistant"); + assert.equal(assistantMsgs.length, 1, "expected exactly one assistant message"); + + const assistantMsg = assistantMsgs[0]; + const thinkingBlock = assistantMsg.content.find((b) => b.type === "thinking"); + const textBlock = assistantMsg.content.find((b) => b.type === "text"); + const toolUseBlock = assistantMsg.content.find((b) => b.type === "tool_use"); + + assert.ok(thinkingBlock, "expected thinking block from reasoning_content"); + assert.equal(thinkingBlock.thinking, "I need to check the weather"); + assert.equal(thinkingBlock.signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); + + assert.ok(textBlock, "expected text block"); + assert.equal(textBlock.text, "Let me check that for you."); + + assert.ok(toolUseBlock, "expected tool_use block"); + assert.equal(toolUseBlock.name, `${CLAUDE_OAUTH_TOOL_PREFIX}get_weather`); + assert.deepEqual(toolUseBlock.input, { location: "Tokyo" }); +}); + +test("OpenAI -> Claude handles assistant tool call messages without reasoning_content when thinking is enabled", () => { + // When thinking is enabled but the assistant message has no reasoning_content, + // the message should still be translated correctly with tool_calls preserved. + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [ + { role: "user", content: "Call a tool" }, + { + role: "assistant", + content: "OK", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "do_thing", + arguments: "{}", + }, + }, + ], + }, + ], + tools: [ + { + type: "function", + function: { + name: "do_thing", + description: "Do a thing", + parameters: { type: "object", properties: {} }, + }, + }, + ], + thinking: { type: "enabled", budget_tokens: 1024 }, + }, + false + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "expected assistant message"); + + const toolUseBlock = assistantMsg.content.find((b) => b.type === "tool_use"); + assert.ok(toolUseBlock, "expected tool_use block to be preserved"); + assert.equal(toolUseBlock.name, `${CLAUDE_OAUTH_TOOL_PREFIX}do_thing`); +});