fix: handle MiniMax string reasoning wrappers (#2039)

This commit is contained in:
zhulinsen
2026-07-19 18:56:08 +08:00
committed by GitHub
parent 5d98d6d787
commit 5aee85679e
7 changed files with 179 additions and 6 deletions
+1
View File
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [新功能] 多策略观点结构化输出第一版:新增策略观点标准化、基础冲突检测与聚合 metadata,作为 #1964 的阶段性基础契约;本次不声明完成并发执行、2–4 策略完整调度 MVP 或前端完整多语言展示。
- [修复] 多策略综合报告统一兼容历史与外部 dashboard 的宽松字段形状,避免非法计数、非字典综合块或异常策略列表导致通知、微信、Jinja 与历史 Markdown 渲染失败。
- [修复] MiniMax 分析与渠道 JSON 测试仅提取最终文本块,避免推理内容与 JSON 拼接后导致结果无法解析和持久化。
- [修复] MiniMax 字符串响应仅剥离开头完整的 `<think>` 推理包装,兼容流式分片并保留 JSON 内容中的同名字面标签。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
+5 -4
View File
@@ -76,6 +76,7 @@ from src.llm.provider_cache import (
build_provider_cache_route_context,
filter_prompt_cache_telemetry,
)
from src.llm.response_content import strip_leading_think_wrapper
from src.storage import persist_llm_usage
from src.data.stock_mapping import STOCK_NAME_MAP
from src.report_language import (
@@ -2863,7 +2864,7 @@ class GeminiAnalyzer:
content_blocks = self._get_response_field(message, "content_blocks")
block_text = self._extract_text_blocks(content_blocks)
if block_text:
return block_text
return strip_leading_think_wrapper(block_text)
content = None
if message is not None:
@@ -2872,9 +2873,9 @@ class GeminiAnalyzer:
content = self._get_response_field(choice, "content")
if isinstance(content, list):
return self._extract_text_blocks(content)
return strip_leading_think_wrapper(self._extract_text_blocks(content))
if isinstance(content, str):
return content.strip()
return strip_leading_think_wrapper(content)
return str(content).strip() if content is not None else ""
def _extract_stream_text(self, chunk: Any) -> str:
@@ -2947,7 +2948,7 @@ class GeminiAnalyzer:
partial_received=chars_received > 0,
) from exc
response_text = "".join(chunks).strip()
response_text = strip_leading_think_wrapper("".join(chunks))
if not response_text:
raise _LiteLLMStreamError(
f"{model} stream returned empty response",
+28
View File
@@ -0,0 +1,28 @@
"""Helpers for normalizing provider response text."""
from __future__ import annotations
_THINK_OPEN_TAG = "<think>"
_THINK_CLOSE_TAG = "</think>"
def strip_leading_think_wrapper(value: str) -> str:
"""Remove one complete leading ``<think>`` wrapper from a response.
The match is deliberately anchored at the start of the response. This
keeps literal ``<think>`` markup inside a JSON string or ordinary answer
untouched and leaves malformed/unclosed wrappers for strict validation to
reject instead of guessing where the final answer begins.
"""
text = str(value or "").strip()
lowered = text.lower()
if not lowered.startswith(_THINK_OPEN_TAG):
return text
close_index = lowered.find(_THINK_CLOSE_TAG, len(_THINK_OPEN_TAG))
if close_index < 0:
return text
return text[close_index + len(_THINK_CLOSE_TAG):].strip()
+7 -2
View File
@@ -67,6 +67,7 @@ from src.llm.backend_registry import (
)
from src.llm.generation_params import apply_litellm_generation_params
from src.llm.local_cli_backend import resolve_local_cli_preset
from src.llm.response_content import strip_leading_think_wrapper
from src.notification_contracts import (
FEISHU_APP_BOT_ENV_GROUP,
FEISHU_WEBHOOK_ENV_GROUP,
@@ -4336,7 +4337,7 @@ class SystemConfigService:
text = _field(block, "content")
if isinstance(text, str) and text:
text_parts.append(text)
return "".join(text_parts).strip()
return strip_leading_think_wrapper("".join(text_parts))
if response is None:
return "", "empty_response", "Completion returned no response object", "null_response"
@@ -4367,7 +4368,11 @@ class SystemConfigService:
raw_content = _field(message, "content")
if raw_content is None:
return "", "empty_response", "Completion returned null message content", "null_content"
content = _text_from_blocks(raw_content) if isinstance(raw_content, list) else str(raw_content).strip()
content = (
_text_from_blocks(raw_content)
if isinstance(raw_content, list)
else strip_leading_think_wrapper(str(raw_content))
)
if not content:
return "", "empty_response", "Completion returned an empty message content", "empty_content"
return content, None, None, None
+31
View File
@@ -0,0 +1,31 @@
import pytest
from src.llm.response_content import strip_leading_think_wrapper
@pytest.mark.parametrize(
("response", "expected"),
[
(
"<THINK>internal reasoning</THINK>\n{\"status\":\"ok\"}",
'{"status":"ok"}',
),
(
'{"summary":"literal <think>text</think>"}',
'{"summary":"literal <think>text</think>"}',
),
(
"prefix <think>internal reasoning</think>{\"status\":\"ok\"}",
"prefix <think>internal reasoning</think>{\"status\":\"ok\"}",
),
(
"<think>unclosed reasoning{\"status\":\"ok\"}",
"<think>unclosed reasoning{\"status\":\"ok\"}",
),
],
)
def test_strip_leading_think_wrapper_is_anchored_and_fail_closed(
response: str,
expected: str,
) -> None:
assert strip_leading_think_wrapper(response) == expected
@@ -1485,6 +1485,63 @@ class TestAnalyzerGenerateText:
assert text == '{"sentiment_score": 72}'
assert model_used == "openai/MiniMax-M3"
def test_call_litellm_minimax_strips_leading_think_wrapper(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
litellm_model="openai/MiniMax-M3",
litellm_fallback_models=[],
llm_model_list=[],
)
response = SimpleNamespace(
choices=[
SimpleNamespace(
content_blocks=None,
message=SimpleNamespace(
content='<think>Internal reasoning</think>\n{"sentiment_score": 72}'
),
)
],
usage=None,
)
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
text, model_used, _usage = analyzer._call_litellm(
"prompt",
{"max_tokens": 128, "temperature": 0.2},
response_validator=analyzer._validate_json_response,
)
assert text == '{"sentiment_score": 72}'
assert model_used == "openai/MiniMax-M3"
def test_call_litellm_preserves_think_markup_inside_json_string(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
litellm_model="openai/MiniMax-M3",
litellm_fallback_models=[],
llm_model_list=[],
)
expected = '{"analysis_summary":"literal <think>text</think>"}'
response = SimpleNamespace(
choices=[
SimpleNamespace(
content_blocks=None,
message=SimpleNamespace(content=expected),
)
],
usage=None,
)
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
text, model_used, _usage = analyzer._call_litellm(
"prompt",
{"max_tokens": 128, "temperature": 0.2},
response_validator=analyzer._validate_json_response,
)
assert text == expected
assert model_used == "openai/MiniMax-M3"
def test_call_litellm_minimax_stream_ignores_reasoning_blocks(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
@@ -1529,6 +1586,37 @@ class TestAnalyzerGenerateText:
assert text == '{"sentiment_score": 72}'
assert model_used == "openai/MiniMax-M3"
def test_call_litellm_minimax_stream_strips_split_think_wrapper(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
litellm_model="openai/MiniMax-M3",
litellm_fallback_models=[],
llm_model_list=[],
)
def stream_response():
for content in (
"<thi",
"nk>Internal reasoning</think>",
'{"sentiment_score": ',
"72}",
):
yield SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(content=content))],
usage=None,
)
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=stream_response()):
text, model_used, _usage = analyzer._call_litellm(
"prompt",
{"max_tokens": 128, "temperature": 0.2},
stream=True,
response_validator=analyzer._validate_json_response,
)
assert text == '{"sentiment_score": 72}'
assert model_used == "openai/MiniMax-M3"
def test_call_litellm_falls_back_to_message_content_when_blocks_empty(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
+19
View File
@@ -3591,6 +3591,25 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
@patch("litellm.completion")
def test_test_llm_channel_json_capability_strips_minimax_think_wrapper(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
self._mock_completion_response('<think>Internal reasoning</think>{"status":"ok"}'),
]
payload = self.service.test_llm_channel(
name="minimax",
protocol="openai",
base_url="https://api.minimax.io/v1",
api_key="sk-test-value",
models=["MiniMax-M3"],
capability_checks=["json"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
@patch("litellm.completion")
def test_test_llm_channel_reports_json_capability_failures(self, mock_completion) -> None:
mock_completion.side_effect = [