diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 117cfb4922..1585846987 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -550,8 +550,9 @@ def _is_empty(content: Any) -> bool: content = msg.get("content") tool_calls = msg.get("tool_calls") + reasoning_content = msg.get("reasoning_content") - if _is_empty(content) and not tool_calls: + if _is_empty(content) and not tool_calls and not reasoning_content: logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") continue @@ -1015,6 +1016,16 @@ def _finally_convert_payload(self, payloads: dict) -> None: model in deepseek_reasoning_models or "api.deepseek.com" in self.client.base_url.host ) + # MiMo 推理模型(MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash) + # 要求 assistant 历史消息必须回传 reasoning_content,否则返回 400 + mimo_reasoning_models = { + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2-flash", + } + is_mimo_reasoning = model in mimo_reasoning_models for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( message.get("content"), list @@ -1043,6 +1054,15 @@ def _finally_convert_payload(self, payloads: dict) -> None: # history messages, even when the reasoning content is empty. message["reasoning_content"] = "" + if ( + message.get("role") == "assistant" + and is_mimo_reasoning + and "reasoning_content" not in message + ): + # MiMo 推理模型要求 assistant 历史消息回传 reasoning_content, + # 缺失时 API 返回 400。参见 MiMo 官方文档。 + message["reasoning_content"] = "" + # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), # 纯文本会触发 400 Invalid argument,需要包一层 JSON。 if is_gemini and message.get("role") == "tool": diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 950e2ea162..a9409bb34d 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1786,3 +1786,126 @@ async def fake_create(**kwargs): assert messages[1] == {"role": "user", "content": "again"} finally: await provider.terminate() + + +# ===== MiMo reasoning_content 回传测试 ===== + +MIMO_REASONING_MODELS = [ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2-flash", +] + +MIMO_NON_REASONING_MODELS = [ + "mimo-v2-tts", + "mimo-v2.5-tts", + "mimo-v2.5-tts-voicedesign", +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", MIMO_REASONING_MODELS) +async def test_mimo_reasoning_model_adds_empty_reasoning_content(model: str): + """MiMo 推理模型:assistant 消息缺少 reasoning_content 时自动补空字符串""" + provider = _make_provider() + try: + payloads = { + "model": model, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "I will help with that.", + }, + ], + } + + provider._finally_convert_payload(payloads) + + assistant = payloads["messages"][1] + assert assistant["reasoning_content"] == "" + finally: + await provider.terminate() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", MIMO_NON_REASONING_MODELS) +async def test_mimo_non_reasoning_model_does_not_add_reasoning_content(model: str): + """MiMo 非推理模型(TTS 等):不应自动注入 reasoning_content""" + provider = _make_provider() + try: + payloads = { + "model": model, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "speaking...", + }, + ], + } + + provider._finally_convert_payload(payloads) + + assistant = payloads["messages"][1] + assert "reasoning_content" not in assistant + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_mimo_reasoning_preserves_existing_reasoning_content(): + """已有 reasoning_content 的 assistant 消息不会被覆盖""" + provider = _make_provider() + try: + payloads = { + "model": "mimo-v2.5-pro", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + {"type": "think", "think": "let me think..."}, + {"type": "text", "text": "here is the answer"}, + ], + }, + ], + } + + provider._finally_convert_payload(payloads) + + assistant = payloads["messages"][1] + assert assistant["reasoning_content"] == "let me think..." + assert assistant["content"] == [{"type": "text", "text": "here is the answer"}] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", MIMO_REASONING_MODELS) +async def test_mimo_filter_preserves_reasoning_only_assistant_message(model: str): + """仅有 reasoning_content 的 assistant 消息不会被 _sanitize 过滤掉""" + provider = _make_provider() + try: + payloads = { + "model": model, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "", + "reasoning_content": "thinking about the answer...", + }, + {"role": "user", "content": "world"}, + ], + } + + provider._sanitize_assistant_messages(payloads) + + messages = payloads["messages"] + assert len(messages) == 3, "含 reasoning_content 的消息不应被过滤" + assert messages[1]["reasoning_content"] == "thinking about the answer..." + finally: + await provider.terminate()