From 1362963d8cf2a0217b166a2e58b584dd2503c461 Mon Sep 17 00:00:00 2001 From: NayukiMeko Date: Mon, 25 May 2026 19:46:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(openai):=20=E4=B8=BAMiMo=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=9E=8B=E8=87=AA=E5=8A=A8=E8=A1=A5=E5=85=85?= =?UTF-8?q?reasoning=5Fcontent=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消息过滤时增加reasoning_content判断,保留仅含思考内容的assistant消息 - 自动为MiMo推理模型的assistant历史消息注入空reasoning_content,满足API要求 - 通过模型名称集合和xiaomimimo.com端点双重判断是否为MiMo推理模型 - 添加单元测试覆盖不同模型识别、字段注入、端点检测和已有内容保留等场景 --- .../core/provider/sources/openai_source.py | 25 +++- tests/test_openai_source.py | 123 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 117cfb4922..7222763e62 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,19 @@ 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 + or "xiaomimimo.com" in self.client.base_url.host + ) for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( message.get("content"), list @@ -1043,6 +1057,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..e1e8bf4810 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_api_host_adds_reasoning_content(): + """通过 xiaomimimo.com 端点调用的模型自动补 reasoning_content""" + import httpx + + provider = _make_provider() + try: + provider.client.base_url = httpx.URL("https://api.xiaomimimo.com/v1") + + payloads = { + "model": "some-unknown-model", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "response", + }, + ], + } + + provider._finally_convert_payload(payloads) + + assistant = payloads["messages"][1] + assert assistant["reasoning_content"] == "" + 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() From 995ee2444be443309e93371b6ee46baa34a945b7 Mon Sep 17 00:00:00 2001 From: NayukiMeko Date: Mon, 25 May 2026 19:53:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(openai):=20=E7=A7=BB=E9=99=A4MiMo?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=A8=A1=E5=9E=8B=E6=A3=80=E6=B5=8B=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E7=AB=AF=E7=82=B9=E4=B8=BB=E6=9C=BA=E5=90=8D=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回退通过xiaomimimo.com主机名自动识别MiMo推理模型的逻辑 - 仅保留基于模型名称集合的判断方式,避免误判非MiMo模型 - 删除对应主机名检测的单元测试用例 --- .../core/provider/sources/openai_source.py | 5 +--- tests/test_openai_source.py | 28 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 7222763e62..1585846987 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -1025,10 +1025,7 @@ def _finally_convert_payload(self, payloads: dict) -> None: "mimo-v2-omni", "mimo-v2-flash", } - is_mimo_reasoning = ( - model in mimo_reasoning_models - or "xiaomimimo.com" in self.client.base_url.host - ) + 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 diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index e1e8bf4810..dc33663801 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1855,34 +1855,6 @@ async def test_mimo_non_reasoning_model_does_not_add_reasoning_content(model: st await provider.terminate() -@pytest.mark.asyncio -async def test_mimo_api_host_adds_reasoning_content(): - """通过 xiaomimimo.com 端点调用的模型自动补 reasoning_content""" - import httpx - - provider = _make_provider() - try: - provider.client.base_url = httpx.URL("https://api.xiaomimimo.com/v1") - - payloads = { - "model": "some-unknown-model", - "messages": [ - {"role": "user", "content": "hello"}, - { - "role": "assistant", - "content": "response", - }, - ], - } - - provider._finally_convert_payload(payloads) - - assistant = payloads["messages"][1] - assert assistant["reasoning_content"] == "" - finally: - await provider.terminate() - - @pytest.mark.asyncio async def test_mimo_reasoning_preserves_existing_reasoning_content(): """已有 reasoning_content 的 assistant 消息不会被覆盖""" From da33ee1bf834a911297f05944025aef7e84f75dd Mon Sep 17 00:00:00 2001 From: NayukiMeko Date: Mon, 25 May 2026 20:03:08 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(openai):=20=E8=A1=A5=E5=85=85MiMo?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=A8=A1=E5=9E=8B=E4=BB=85=E5=90=ABreasoning?= =?UTF-8?q?=5Fcontent=E6=B6=88=E6=81=AF=E4=B8=8D=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加test_mimo_filter_preserves_reasoning_only_assistant_message参数化测试 - 验证仅有reasoning_content的assistant消息不会被_sanitize过滤 - 确保包含reasoning_content的空content消息仍保留在对话历史中 --- tests/test_openai_source.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index dc33663801..a9409bb34d 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1881,3 +1881,31 @@ async def test_mimo_reasoning_preserves_existing_reasoning_content(): 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()