From 1da6b87b2763da8fcd4a92fa4374c7c4cb565672 Mon Sep 17 00:00:00 2001 From: kaixinyujue <2465367308@qq.com> Date: Mon, 30 Mar 2026 22:30:23 +0800 Subject: [PATCH 1/4] fix: filter empty assistant messages to prevent 400 error on strict APIs Some OpenAI-compatible APIs (e.g., Moonshot) reject requests with empty content in assistant messages when no tool_calls are present. This fix cleans up the messages payload before sending to avoid 'message at position X must not be empty' errors. Closes related issue with fallback provider behavior. --- .../core/provider/sources/openai_source.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 39bfc69dbf..3f9127de73 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -457,6 +457,34 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: model = payloads.get("model", "").lower() + if "messages" in payloads and isinstance(payloads["messages"], list): + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + # 针对 assistant 角色的特殊处理 + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + # 情况1: content 为空字符串且没有工具调用 -> 删除 + if content == "" and not tool_calls: + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + # 情况2: content 为 None 但没有工具调用 -> 删除或赋予空字符串 + if content is None and not tool_calls: + logger.warning( + f"过滤第 {idx} 条 null content 的 assistant 消息" + ) + continue + + # 情况3: content 为 "" 但有工具调用 -> 改为 None (符合 OpenAI 规范) + if content == "" and tool_calls: + msg["content"] = None + + cleaned_messages.append(msg) + + payloads["messages"] = cleaned_messages + completion = await self.client.chat.completions.create( **payloads, stream=False, From 80ab339b9e1c1a63a5538ed23f4ad7b0f995258f Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 2 Apr 2026 09:02:40 +0800 Subject: [PATCH 2/4] test(openai): add tests for empty assistant message filtering --- tests/test_openai_source.py | 357 ++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 39bb6d3810..e92267c9ee 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1173,3 +1173,360 @@ async def test_parse_openai_completion_raises_empty_model_output_error(): await provider._parse_openai_completion(completion, tools=None) finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch): + """Test that empty assistant messages without tool_calls are filtered out.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": ""}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # The empty assistant message should be filtered out + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_filters_null_content_assistant_message_without_tool_calls( + monkeypatch, +): + """Test that assistant messages with null content and no tool_calls are filtered.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": None}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # The null content assistant message should be filtered out + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_converts_empty_content_to_none_with_tool_calls(monkeypatch): + """Test that empty content with tool_calls is converted to None (OpenAI spec).""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-123", + "type": "function", + "function": {"name": "test", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # The assistant message with tool_calls should be kept but content set to None + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1]["role"] == "assistant" + assert messages[1]["content"] is None + assert messages[1]["tool_calls"] is not None + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_keeps_valid_assistant_message_with_content(monkeypatch): + """Test that valid assistant messages with content are kept.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "response"}, + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # All messages should be kept + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1] == {"role": "assistant", "content": "response"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_keeps_assistant_message_with_tool_calls_and_none_content( + monkeypatch, +): + """Test that assistant messages with tool_calls and None content are kept.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-123", + "type": "function", + "function": {"name": "test", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # The assistant message with tool_calls should be kept + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1]["role"] == "assistant" + assert messages[1]["content"] is None + assert messages[1]["tool_calls"] is not None + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_does_not_filter_user_or_system_messages(monkeypatch): + """Test that user and system messages are not affected by the filter.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": ""}, # Empty system message + {"role": "user", "content": ""}, # Empty user message + {"role": "assistant", "content": ""}, # Should be filtered + {"role": "user", "content": "hello"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + # Only assistant message should be filtered + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[0] == {"role": "system", "content": ""} + assert messages[1] == {"role": "user", "content": ""} + assert messages[2] == {"role": "user", "content": "hello"} + finally: + await provider.terminate() From ed497cb88015a24f156ae2b98d59d5f7703b4c6f Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 2 Apr 2026 09:05:07 +0800 Subject: [PATCH 3/4] refactor(openai): simplify empty assistant message filtering logic --- astrbot/core/provider/sources/openai_source.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 3f9127de73..c5415ecc56 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -460,24 +460,19 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if "messages" in payloads and isinstance(payloads["messages"], list): cleaned_messages = [] for idx, msg in enumerate(payloads["messages"]): - # 针对 assistant 角色的特殊处理 + # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误 if msg.get("role") == "assistant": content = msg.get("content") tool_calls = msg.get("tool_calls") - # 情况1: content 为空字符串且没有工具调用 -> 删除 - if content == "" and not tool_calls: - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") - continue - - # 情况2: content 为 None 但没有工具调用 -> 删除或赋予空字符串 - if content is None and not tool_calls: + # 情况1: 空/null content 且无 tool_calls -> 过滤掉 + if not tool_calls and (content == "" or content is None): logger.warning( - f"过滤第 {idx} 条 null content 的 assistant 消息" + f"过滤第 {idx} 条空 assistant 消息 (无工具调用)" ) continue - # 情况3: content 为 "" 但有工具调用 -> 改为 None (符合 OpenAI 规范) + # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范) if content == "" and tool_calls: msg["content"] = None From d79a18ebf5af904fea204ac88113fde8b348d083 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 2 Apr 2026 09:07:43 +0800 Subject: [PATCH 4/4] style: format code --- astrbot/core/provider/sources/openai_source.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index c5415ecc56..bbf38f7b0c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -467,9 +467,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: # 情况1: 空/null content 且无 tool_calls -> 过滤掉 if not tool_calls and (content == "" or content is None): - logger.warning( - f"过滤第 {idx} 条空 assistant 消息 (无工具调用)" - ) + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") continue # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)