mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
修复:过滤空助手消息,以防止在严格API上出现400错误(fix: filter empty assistant messages to prevent 400 error on strict APIs) (#7202)
* 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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user