Skip to content

Commit 0ecddb4

Browse files
kaixinyujueRC-CHN
andauthored
修复:过滤空助手消息,以防止在严格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>
1 parent 2de2318 commit 0ecddb4

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed

astrbot/core/provider/sources/openai_source.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,27 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
457457

458458
model = payloads.get("model", "").lower()
459459

460+
if "messages" in payloads and isinstance(payloads["messages"], list):
461+
cleaned_messages = []
462+
for idx, msg in enumerate(payloads["messages"]):
463+
# 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误
464+
if msg.get("role") == "assistant":
465+
content = msg.get("content")
466+
tool_calls = msg.get("tool_calls")
467+
468+
# 情况1: 空/null content 且无 tool_calls -> 过滤掉
469+
if not tool_calls and (content == "" or content is None):
470+
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
471+
continue
472+
473+
# 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
474+
if content == "" and tool_calls:
475+
msg["content"] = None
476+
477+
cleaned_messages.append(msg)
478+
479+
payloads["messages"] = cleaned_messages
480+
460481
completion = await self.client.chat.completions.create(
461482
**payloads,
462483
stream=False,

tests/test_openai_source.py

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,3 +1173,360 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
11731173
await provider._parse_openai_completion(completion, tools=None)
11741174
finally:
11751175
await provider.terminate()
1176+
1177+
1178+
@pytest.mark.asyncio
1179+
async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch):
1180+
"""Test that empty assistant messages without tool_calls are filtered out."""
1181+
provider = _make_provider()
1182+
try:
1183+
captured_kwargs = {}
1184+
1185+
async def fake_create(**kwargs):
1186+
captured_kwargs.update(kwargs)
1187+
return ChatCompletion.model_validate(
1188+
{
1189+
"id": "chatcmpl-test",
1190+
"object": "chat.completion",
1191+
"created": 0,
1192+
"model": "gpt-4o-mini",
1193+
"choices": [
1194+
{
1195+
"index": 0,
1196+
"message": {
1197+
"role": "assistant",
1198+
"content": "ok",
1199+
},
1200+
"finish_reason": "stop",
1201+
}
1202+
],
1203+
"usage": {
1204+
"prompt_tokens": 1,
1205+
"completion_tokens": 1,
1206+
"total_tokens": 2,
1207+
},
1208+
}
1209+
)
1210+
1211+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1212+
1213+
payloads = {
1214+
"model": "gpt-4o-mini",
1215+
"messages": [
1216+
{"role": "user", "content": "hello"},
1217+
{"role": "assistant", "content": ""}, # Should be filtered
1218+
{"role": "user", "content": "world"},
1219+
],
1220+
}
1221+
1222+
await provider._query(payloads=payloads, tools=None)
1223+
1224+
# The empty assistant message should be filtered out
1225+
messages = captured_kwargs["messages"]
1226+
assert len(messages) == 2
1227+
assert messages[0] == {"role": "user", "content": "hello"}
1228+
assert messages[1] == {"role": "user", "content": "world"}
1229+
finally:
1230+
await provider.terminate()
1231+
1232+
1233+
@pytest.mark.asyncio
1234+
async def test_query_filters_null_content_assistant_message_without_tool_calls(
1235+
monkeypatch,
1236+
):
1237+
"""Test that assistant messages with null content and no tool_calls are filtered."""
1238+
provider = _make_provider()
1239+
try:
1240+
captured_kwargs = {}
1241+
1242+
async def fake_create(**kwargs):
1243+
captured_kwargs.update(kwargs)
1244+
return ChatCompletion.model_validate(
1245+
{
1246+
"id": "chatcmpl-test",
1247+
"object": "chat.completion",
1248+
"created": 0,
1249+
"model": "gpt-4o-mini",
1250+
"choices": [
1251+
{
1252+
"index": 0,
1253+
"message": {
1254+
"role": "assistant",
1255+
"content": "ok",
1256+
},
1257+
"finish_reason": "stop",
1258+
}
1259+
],
1260+
"usage": {
1261+
"prompt_tokens": 1,
1262+
"completion_tokens": 1,
1263+
"total_tokens": 2,
1264+
},
1265+
}
1266+
)
1267+
1268+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1269+
1270+
payloads = {
1271+
"model": "gpt-4o-mini",
1272+
"messages": [
1273+
{"role": "user", "content": "hello"},
1274+
{"role": "assistant", "content": None}, # Should be filtered
1275+
{"role": "user", "content": "world"},
1276+
],
1277+
}
1278+
1279+
await provider._query(payloads=payloads, tools=None)
1280+
1281+
# The null content assistant message should be filtered out
1282+
messages = captured_kwargs["messages"]
1283+
assert len(messages) == 2
1284+
assert messages[0] == {"role": "user", "content": "hello"}
1285+
assert messages[1] == {"role": "user", "content": "world"}
1286+
finally:
1287+
await provider.terminate()
1288+
1289+
1290+
@pytest.mark.asyncio
1291+
async def test_query_converts_empty_content_to_none_with_tool_calls(monkeypatch):
1292+
"""Test that empty content with tool_calls is converted to None (OpenAI spec)."""
1293+
provider = _make_provider()
1294+
try:
1295+
captured_kwargs = {}
1296+
1297+
async def fake_create(**kwargs):
1298+
captured_kwargs.update(kwargs)
1299+
return ChatCompletion.model_validate(
1300+
{
1301+
"id": "chatcmpl-test",
1302+
"object": "chat.completion",
1303+
"created": 0,
1304+
"model": "gpt-4o-mini",
1305+
"choices": [
1306+
{
1307+
"index": 0,
1308+
"message": {
1309+
"role": "assistant",
1310+
"content": "ok",
1311+
},
1312+
"finish_reason": "stop",
1313+
}
1314+
],
1315+
"usage": {
1316+
"prompt_tokens": 1,
1317+
"completion_tokens": 1,
1318+
"total_tokens": 2,
1319+
},
1320+
}
1321+
)
1322+
1323+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1324+
1325+
payloads = {
1326+
"model": "gpt-4o-mini",
1327+
"messages": [
1328+
{"role": "user", "content": "hello"},
1329+
{
1330+
"role": "assistant",
1331+
"content": "",
1332+
"tool_calls": [
1333+
{
1334+
"id": "call-123",
1335+
"type": "function",
1336+
"function": {"name": "test", "arguments": "{}"},
1337+
}
1338+
],
1339+
},
1340+
{"role": "user", "content": "world"},
1341+
],
1342+
}
1343+
1344+
await provider._query(payloads=payloads, tools=None)
1345+
1346+
# The assistant message with tool_calls should be kept but content set to None
1347+
messages = captured_kwargs["messages"]
1348+
assert len(messages) == 3
1349+
assert messages[1]["role"] == "assistant"
1350+
assert messages[1]["content"] is None
1351+
assert messages[1]["tool_calls"] is not None
1352+
finally:
1353+
await provider.terminate()
1354+
1355+
1356+
@pytest.mark.asyncio
1357+
async def test_query_keeps_valid_assistant_message_with_content(monkeypatch):
1358+
"""Test that valid assistant messages with content are kept."""
1359+
provider = _make_provider()
1360+
try:
1361+
captured_kwargs = {}
1362+
1363+
async def fake_create(**kwargs):
1364+
captured_kwargs.update(kwargs)
1365+
return ChatCompletion.model_validate(
1366+
{
1367+
"id": "chatcmpl-test",
1368+
"object": "chat.completion",
1369+
"created": 0,
1370+
"model": "gpt-4o-mini",
1371+
"choices": [
1372+
{
1373+
"index": 0,
1374+
"message": {
1375+
"role": "assistant",
1376+
"content": "ok",
1377+
},
1378+
"finish_reason": "stop",
1379+
}
1380+
],
1381+
"usage": {
1382+
"prompt_tokens": 1,
1383+
"completion_tokens": 1,
1384+
"total_tokens": 2,
1385+
},
1386+
}
1387+
)
1388+
1389+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1390+
1391+
payloads = {
1392+
"model": "gpt-4o-mini",
1393+
"messages": [
1394+
{"role": "user", "content": "hello"},
1395+
{"role": "assistant", "content": "response"},
1396+
{"role": "user", "content": "world"},
1397+
],
1398+
}
1399+
1400+
await provider._query(payloads=payloads, tools=None)
1401+
1402+
# All messages should be kept
1403+
messages = captured_kwargs["messages"]
1404+
assert len(messages) == 3
1405+
assert messages[1] == {"role": "assistant", "content": "response"}
1406+
finally:
1407+
await provider.terminate()
1408+
1409+
1410+
@pytest.mark.asyncio
1411+
async def test_query_keeps_assistant_message_with_tool_calls_and_none_content(
1412+
monkeypatch,
1413+
):
1414+
"""Test that assistant messages with tool_calls and None content are kept."""
1415+
provider = _make_provider()
1416+
try:
1417+
captured_kwargs = {}
1418+
1419+
async def fake_create(**kwargs):
1420+
captured_kwargs.update(kwargs)
1421+
return ChatCompletion.model_validate(
1422+
{
1423+
"id": "chatcmpl-test",
1424+
"object": "chat.completion",
1425+
"created": 0,
1426+
"model": "gpt-4o-mini",
1427+
"choices": [
1428+
{
1429+
"index": 0,
1430+
"message": {
1431+
"role": "assistant",
1432+
"content": "ok",
1433+
},
1434+
"finish_reason": "stop",
1435+
}
1436+
],
1437+
"usage": {
1438+
"prompt_tokens": 1,
1439+
"completion_tokens": 1,
1440+
"total_tokens": 2,
1441+
},
1442+
}
1443+
)
1444+
1445+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1446+
1447+
payloads = {
1448+
"model": "gpt-4o-mini",
1449+
"messages": [
1450+
{"role": "user", "content": "hello"},
1451+
{
1452+
"role": "assistant",
1453+
"content": None,
1454+
"tool_calls": [
1455+
{
1456+
"id": "call-123",
1457+
"type": "function",
1458+
"function": {"name": "test", "arguments": "{}"},
1459+
}
1460+
],
1461+
},
1462+
{"role": "user", "content": "world"},
1463+
],
1464+
}
1465+
1466+
await provider._query(payloads=payloads, tools=None)
1467+
1468+
# The assistant message with tool_calls should be kept
1469+
messages = captured_kwargs["messages"]
1470+
assert len(messages) == 3
1471+
assert messages[1]["role"] == "assistant"
1472+
assert messages[1]["content"] is None
1473+
assert messages[1]["tool_calls"] is not None
1474+
finally:
1475+
await provider.terminate()
1476+
1477+
1478+
@pytest.mark.asyncio
1479+
async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
1480+
"""Test that user and system messages are not affected by the filter."""
1481+
provider = _make_provider()
1482+
try:
1483+
captured_kwargs = {}
1484+
1485+
async def fake_create(**kwargs):
1486+
captured_kwargs.update(kwargs)
1487+
return ChatCompletion.model_validate(
1488+
{
1489+
"id": "chatcmpl-test",
1490+
"object": "chat.completion",
1491+
"created": 0,
1492+
"model": "gpt-4o-mini",
1493+
"choices": [
1494+
{
1495+
"index": 0,
1496+
"message": {
1497+
"role": "assistant",
1498+
"content": "ok",
1499+
},
1500+
"finish_reason": "stop",
1501+
}
1502+
],
1503+
"usage": {
1504+
"prompt_tokens": 1,
1505+
"completion_tokens": 1,
1506+
"total_tokens": 2,
1507+
},
1508+
}
1509+
)
1510+
1511+
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
1512+
1513+
payloads = {
1514+
"model": "gpt-4o-mini",
1515+
"messages": [
1516+
{"role": "system", "content": ""}, # Empty system message
1517+
{"role": "user", "content": ""}, # Empty user message
1518+
{"role": "assistant", "content": ""}, # Should be filtered
1519+
{"role": "user", "content": "hello"},
1520+
],
1521+
}
1522+
1523+
await provider._query(payloads=payloads, tools=None)
1524+
1525+
# Only assistant message should be filtered
1526+
messages = captured_kwargs["messages"]
1527+
assert len(messages) == 3
1528+
assert messages[0] == {"role": "system", "content": ""}
1529+
assert messages[1] == {"role": "user", "content": ""}
1530+
assert messages[2] == {"role": "user", "content": "hello"}
1531+
finally:
1532+
await provider.terminate()

0 commit comments

Comments
 (0)