From 80478abea1dbad090ae4fa9f43bdf140cd068b04 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:26:08 +0100 Subject: [PATCH] fix: assistant messages with only tool calls are sent with an empty string instead of null (#34265) When an assistant message has tool calls but no text, the OpenAI Chat Completions format uses `content: null`, since content is optional next to tool calls and OpenAI's own API returns null there. Open WebUI sent an empty string in two places: when replaying earlier tool calls from the chat history, and when converting requests from clients that use its Anthropic-compatible endpoint. For Claude models, LiteLLM 1.81.14 through 1.108.x replaces that empty string with "[System: Empty message content sanitised to satisfy protocol]", and the model then repeats that line in its replies (LiteLLM 1.109 and later leave the empty text out instead). These messages now go out with null content, which OpenAI and every provider tested on OpenRouter (Gemini, Claude, DeepSeek, Qwen, Mistral, Llama, Kimi and Grok) accept. Fixes #34264 --- backend/open_webui/utils/anthropic.py | 2 +- backend/open_webui/utils/memory.py | 2 +- backend/open_webui/utils/middleware.py | 2 +- backend/open_webui/utils/misc.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 5f1498c0f1..f0d49d8578 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -364,7 +364,7 @@ def convert_anthropic_payload_to_chat_completions( if openai_content: msg_dict['content'] = _finalize_chat_completions_content(openai_content) else: - msg_dict['content'] = '' + msg_dict['content'] = None msg_dict['tool_calls'] = tool_calls messages.append(msg_dict) elif openai_content or role == 'assistant': diff --git a/backend/open_webui/utils/memory.py b/backend/open_webui/utils/memory.py index 11e405c1a9..e27bf69d4e 100644 --- a/backend/open_webui/utils/memory.py +++ b/backend/open_webui/utils/memory.py @@ -500,7 +500,7 @@ async def _review_memory( role = message.get('role', '') content = message.get('content', '') if not isinstance(content, str): - content = get_content_from_message(message) + content = get_content_from_message(message) or '' content = content.strip() if role not in {'user', 'assistant'} or not content: continue diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 26293e95a3..82e7aef1c6 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1029,7 +1029,7 @@ async def chat_completion_tools_handler( recent_messages = messages[-4:] if len(messages) > 4 else messages chat_history = '\n'.join( - f'{message["role"].upper()}: """{get_content_from_message(message)}"""' for message in recent_messages + f'{message["role"].upper()}: """{get_content_from_message(message) or ""}"""' for message in recent_messages ) prompt = f'History:\n{chat_history}\nQuery: {user_message}' if chat_history else f'Query: {user_message}' diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 25561c0f5b..7cea189b9e 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -223,7 +223,7 @@ def get_message_list(messages_map, message_id): def get_messages_content(messages: list[dict]) -> str: - return '\n'.join([f'{message["role"].upper()}: {get_content_from_message(message)}' for message in messages]) + return '\n'.join([f'{message["role"].upper()}: {get_content_from_message(message) or ""}' for message in messages]) def get_last_user_message_item(messages: list[dict]) -> dict | None: @@ -477,7 +477,7 @@ def convert_output_to_messages( message = { 'role': 'assistant', - 'content': '\n'.join(pending_content) if pending_content else '', + 'content': '\n'.join(pending_content) if pending_content else (None if pending_tool_calls else ''), **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), }