From 0ecddb4c06c0cbc85d91c6ea560484aa9747459f Mon Sep 17 00:00:00 2001 From: kaixinyujue <37257291+kaixinyujue@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:10:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E7=A9=BA=E5=8A=A9=E6=89=8B=E6=B6=88=E6=81=AF=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E5=9C=A8=E4=B8=A5=E6=A0=BCAPI=E4=B8=8A?= =?UTF-8?q?=E5=87=BA=E7=8E=B0400=E9=94=99=E8=AF=AF=EF=BC=88fix:=20filter?= =?UTF-8?q?=20empty=20assistant=20messages=20to=20prevent=20400=20error=20?= =?UTF-8?q?on=20strict=20APIs=EF=BC=89=20(#7202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * test(openai): add tests for empty assistant message filtering * refactor(openai): simplify empty assistant message filtering logic * style: format code --------- Co-authored-by: RC-CHN <1051989940@qq.com> --- .../core/provider/sources/openai_source.py | 21 ++ tests/test_openai_source.py | 357 ++++++++++++++++++ 2 files changed, 378 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index d4436f871..d81185344 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -457,6 +457,27 @@ class ProviderOpenAIOfficial(Provider): model = payloads.get("model", "").lower() + if "messages" in payloads and isinstance(payloads["messages"], list): + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误 + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + # 情况1: 空/null content 且无 tool_calls -> 过滤掉 + if not tool_calls and (content == "" or content is None): + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + # 情况2: 空 content 但有 tool_calls -> 设为 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, diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 39bb6d381..e92267c9e 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()