fix(sanitizer): preserve reasoning_content on assistant messages with tool_calls (#2140)

Integrated into release/v3.8.0 — preserves reasoning_content on assistant messages with tool_calls/function_call, fixing Kimi 400 errors.
This commit is contained in:
Davy Massoneto
2026-05-10 21:25:32 -03:00
committed by GitHub
parent f8812b95c7
commit 4e53fba8b9
3 changed files with 207 additions and 3 deletions

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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`);
});