mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user