diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 39bfc69dbf..bbf38f7b0c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -457,6 +457,27 @@ 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 消息,防止严格 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 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()