mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-24 05:44:07 +00:00
Fix Kimi tool-call continuation reasoning
This commit is contained in:
parent
f162d4de90
commit
2383e94985
3 changed files with 283 additions and 3 deletions
158
backend/open_webui/test/util/test_misc.py
Normal file
158
backend/open_webui/test/util/test_misc.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from open_webui.utils.misc import add_reasoning_content_to_tool_messages
|
||||
|
||||
|
||||
class TestAddReasoningContentToToolMessages:
|
||||
def test_adds_reasoning_content_for_kimi_tool_messages(self):
|
||||
messages = [
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '<think>Need to search.</think>',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_123',
|
||||
'type': 'function',
|
||||
'function': {'name': 'search_web', 'arguments': '{"query":"Rakhi Kaag"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{'role': 'tool', 'tool_call_id': 'call_123', 'content': 'Search results'},
|
||||
]
|
||||
output = [
|
||||
{
|
||||
'type': 'reasoning',
|
||||
'summary': [{'type': 'output_text', 'text': 'Need to search.'}],
|
||||
},
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': 'call_123',
|
||||
'name': 'search_web',
|
||||
'arguments': '{"query":"Rakhi Kaag"}',
|
||||
},
|
||||
{'type': 'function_call_output', 'call_id': 'call_123', 'output': []},
|
||||
]
|
||||
|
||||
adjusted = add_reasoning_content_to_tool_messages(messages, output, 'kimi-k2.6')
|
||||
|
||||
assert adjusted[0]['reasoning_content'] == 'Need to search.'
|
||||
assert adjusted[1] == messages[1]
|
||||
|
||||
def test_scopes_reasoning_to_matching_assistant_message(self):
|
||||
messages = [
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '<think>Need to search the web.</think>',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {'name': 'search_web', 'arguments': '{"query":"Rakhi Kaag"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{'role': 'tool', 'tool_call_id': 'call_1', 'content': 'First search results'},
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '<think>Need to search notes.</think>',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_2',
|
||||
'type': 'function',
|
||||
'function': {'name': 'search_notes', 'arguments': '{"query":"Rakhi Kaag"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
output = [
|
||||
{
|
||||
'type': 'reasoning',
|
||||
'summary': [{'type': 'output_text', 'text': 'Need to search the web.'}],
|
||||
},
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': 'call_1',
|
||||
'name': 'search_web',
|
||||
'arguments': '{"query":"Rakhi Kaag"}',
|
||||
},
|
||||
{'type': 'function_call_output', 'call_id': 'call_1', 'output': []},
|
||||
{
|
||||
'type': 'reasoning',
|
||||
'summary': [{'type': 'output_text', 'text': 'Need to search notes.'}],
|
||||
},
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': 'call_2',
|
||||
'name': 'search_notes',
|
||||
'arguments': '{"query":"Rakhi Kaag"}',
|
||||
},
|
||||
{'type': 'function_call_output', 'call_id': 'call_2', 'output': []},
|
||||
]
|
||||
|
||||
adjusted = add_reasoning_content_to_tool_messages(messages, output, 'kimi-k2.6')
|
||||
|
||||
assert adjusted[0]['reasoning_content'] == 'Need to search the web.'
|
||||
assert adjusted[2]['reasoning_content'] == 'Need to search notes.'
|
||||
|
||||
def test_leaves_non_kimi_models_unchanged(self):
|
||||
messages = [
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '<think>Need to search.</think>',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_123',
|
||||
'type': 'function',
|
||||
'function': {'name': 'search_web', 'arguments': '{"query":"Rakhi Kaag"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
output = [
|
||||
{
|
||||
'type': 'reasoning',
|
||||
'summary': [{'type': 'output_text', 'text': 'Need to search.'}],
|
||||
},
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': 'call_123',
|
||||
'name': 'search_web',
|
||||
'arguments': '{"query":"Rakhi Kaag"}',
|
||||
},
|
||||
{'type': 'function_call_output', 'call_id': 'call_123', 'output': []},
|
||||
]
|
||||
|
||||
adjusted = add_reasoning_content_to_tool_messages(messages, output, 'gpt-4.1')
|
||||
|
||||
assert adjusted == messages
|
||||
|
||||
def test_keeps_existing_reasoning_content(self):
|
||||
messages = [
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '<think>Need to search.</think>',
|
||||
'reasoning_content': 'Existing reasoning',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_123',
|
||||
'type': 'function',
|
||||
'function': {'name': 'search_web', 'arguments': '{"query":"Rakhi Kaag"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
output = [
|
||||
{
|
||||
'type': 'reasoning',
|
||||
'summary': [{'type': 'output_text', 'text': 'Need to search.'}],
|
||||
},
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': 'call_123',
|
||||
'name': 'search_web',
|
||||
'arguments': '{"query":"Rakhi Kaag"}',
|
||||
},
|
||||
{'type': 'function_call_output', 'call_id': 'call_123', 'output': []},
|
||||
]
|
||||
|
||||
adjusted = add_reasoning_content_to_tool_messages(messages, output, 'moonshot-v1')
|
||||
|
||||
assert adjusted[0]['reasoning_content'] == 'Existing reasoning'
|
||||
|
|
@ -98,6 +98,7 @@ from open_webui.utils.misc import (
|
|||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
convert_output_to_messages,
|
||||
add_reasoning_content_to_tool_messages,
|
||||
strip_empty_content_blocks,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
|
|
@ -4660,12 +4661,17 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
|
||||
system_message = get_system_message(form_data['messages'])
|
||||
tool_messages = add_reasoning_content_to_tool_messages(
|
||||
convert_output_to_messages(output, raw=True), output, model_id
|
||||
)
|
||||
new_form_data['messages'] = (
|
||||
[system_message] if system_message else []
|
||||
) + convert_output_to_messages(output, raw=True)
|
||||
) + tool_messages
|
||||
new_form_data['previous_response_id'] = last_response_id
|
||||
else:
|
||||
tool_messages = convert_output_to_messages(output, raw=True)
|
||||
tool_messages = add_reasoning_content_to_tool_messages(
|
||||
convert_output_to_messages(output, raw=True), output, model_id
|
||||
)
|
||||
|
||||
# Chat Completions providers don't support multimodal
|
||||
# tool messages. Extract images into a user message.
|
||||
|
|
@ -4875,13 +4881,16 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
try:
|
||||
tool_messages = add_reasoning_content_to_tool_messages(
|
||||
convert_output_to_messages(output, raw=True), output, model_id
|
||||
)
|
||||
new_form_data = {
|
||||
**form_data,
|
||||
'model': model_id,
|
||||
'stream': True,
|
||||
'messages': [
|
||||
*form_data['messages'],
|
||||
*convert_output_to_messages(output, raw=True),
|
||||
*tool_messages,
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,119 @@ def get_content_from_message(message: dict) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def model_requires_reasoning_content_for_tool_calls(model_id: Optional[str]) -> bool:
|
||||
"""
|
||||
Some OpenAI-compatible thinking models require assistant tool-call messages
|
||||
to preserve top-level reasoning_content on continuation turns.
|
||||
"""
|
||||
if not model_id:
|
||||
return False
|
||||
|
||||
model_name = model_id.lower()
|
||||
return 'kimi' in model_name or 'moonshot' in model_name
|
||||
|
||||
|
||||
def _extract_reasoning_content_from_item(item: dict) -> str:
|
||||
reasoning_parts = []
|
||||
|
||||
source_list = item.get('summary', []) or item.get('content', [])
|
||||
for part in source_list:
|
||||
text = part.get('text', '')
|
||||
if text:
|
||||
reasoning_parts.append(text)
|
||||
|
||||
return ''.join(reasoning_parts).strip()
|
||||
|
||||
|
||||
def get_assistant_reasoning_content_from_output(output: list[dict]) -> list[str]:
|
||||
"""
|
||||
Mirror convert_output_to_messages() assistant-message boundaries so
|
||||
reasoning_content can be reattached to the matching assistant tool-call
|
||||
message instead of being applied globally.
|
||||
"""
|
||||
assistant_reasoning = []
|
||||
pending_reasoning = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
|
||||
def flush_pending():
|
||||
nonlocal pending_reasoning, pending_tool_calls, pending_content
|
||||
if pending_content or pending_tool_calls:
|
||||
assistant_reasoning.append(''.join(pending_reasoning).strip())
|
||||
pending_reasoning = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
|
||||
if item_type == 'message':
|
||||
content_parts = item.get('content', [])
|
||||
text = ''
|
||||
for part in content_parts:
|
||||
if part.get('type') == 'output_text':
|
||||
text += part.get('text', '')
|
||||
if text:
|
||||
pending_content.append(text)
|
||||
|
||||
elif item_type == 'function_call':
|
||||
pending_tool_calls.append(item)
|
||||
|
||||
elif item_type == 'function_call_output':
|
||||
flush_pending()
|
||||
|
||||
elif item_type == 'reasoning':
|
||||
reasoning_text = _extract_reasoning_content_from_item(item)
|
||||
if reasoning_text:
|
||||
pending_reasoning.append(reasoning_text)
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
if item.get('code') or item.get('output'):
|
||||
pending_content.append('code_interpreter')
|
||||
|
||||
elif item_type.startswith('open_webui:'):
|
||||
pass
|
||||
|
||||
flush_pending()
|
||||
return assistant_reasoning
|
||||
|
||||
|
||||
def add_reasoning_content_to_tool_messages(
|
||||
messages: list[dict], output: list[dict], model_id: Optional[str]
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Re-add top-level reasoning_content to assistant tool-call messages for
|
||||
providers that require it during the post-tool continuation request.
|
||||
"""
|
||||
if not model_requires_reasoning_content_for_tool_calls(model_id):
|
||||
return messages
|
||||
|
||||
assistant_reasoning = get_assistant_reasoning_content_from_output(output)
|
||||
if not any(assistant_reasoning):
|
||||
return messages
|
||||
|
||||
adjusted_messages = []
|
||||
assistant_index = 0
|
||||
for message in messages:
|
||||
reasoning_content = None
|
||||
if message.get('role') == 'assistant':
|
||||
if assistant_index < len(assistant_reasoning):
|
||||
reasoning_content = assistant_reasoning[assistant_index]
|
||||
assistant_index += 1
|
||||
|
||||
if (
|
||||
message.get('role') == 'assistant'
|
||||
and message.get('tool_calls')
|
||||
and reasoning_content
|
||||
and not message.get('reasoning_content')
|
||||
):
|
||||
message = {**message, 'reasoning_content': reasoning_content}
|
||||
|
||||
adjusted_messages.append(message)
|
||||
|
||||
return adjusted_messages
|
||||
|
||||
|
||||
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue