fix(provider): strip think tags for MiniMax-M3 and other OpenAI-compatible models (#2330)

* fix(provider): strip think tags for MiniMax-M3 and other OpenAI-compatible models

MiniMax-M3 (and other OpenAI-compatible providers) emit chain-of-thought
reasoning directly in the content field wrapped in  tags, instead
of using a separate reasoning_content field or the legacy CRETIRE_REASONING
markers. The existing remove_think logic only handled CRETIRE_* tags, so
think blocks leaked into user-visible output even when remove_think was enabled.

- Add _ThinkStripState: a stateful filter that correctly handles  tags
  split across streaming chunk boundaries.
- Add _strip_think classmethod with regex patterns for both  and
  CRETIRE_* tags.
- Wire think_state into invoke_llm_stream so deltas are filtered before
  reaching the accumulator.
- Add remove_think safety net in _StreamAccumulator so the final message
  from tool-call rounds also gets stripped.
- Fix remove_think resolution to use defensive nested .get() so
  pipelines missing output.misc don't raise AttributeError.

* fix(litellmchat): add missing _CLOSE_TAG class attribute on _ThinkStripState

* fix(provider): handle think stripping across LiteLLM paths

---------

Co-authored-by: WangCham <651122857@qq.com>
This commit is contained in:
DongXiaoming
2026-07-15 10:54:41 +08:00
committed by GitHub
parent bd35b793e1
commit c53b800267
4 changed files with 431 additions and 18 deletions
@@ -279,6 +279,122 @@ class TestInvokeLLMStreamUsage:
assert query.variables['_stream_usage']['total_tokens'] == 12
@pytest.mark.asyncio
async def test_stream_removes_leading_think_across_chunks(self):
"""A leading think block split across chunks must be removed."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('minimax-m3', 'test-api-key')
chunks = [
self._make_chunk(content='<thi'),
self._make_chunk(content='nk>hidden'),
self._make_chunk(content=' reasoning</thi'),
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
@pytest.mark.asyncio
async def test_stream_removes_initial_orphan_think_close(self):
"""Initial reasoning content without an open tag is removed until </think>."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('minimax-m3', 'test-api-key')
chunks = [
self._make_chunk(content='hidden reasoning'),
self._make_chunk(content=' still hidden</thi'),
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
@pytest.mark.asyncio
async def test_stream_removes_non_leading_think_content(self):
"""A think block in the answer body is removed with its content."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('gpt-4o', 'test-api-key')
chunks = [
self._make_chunk(content='Use <think>x</think> as an XML-like example.', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Use as an XML-like example.'
@pytest.mark.asyncio
async def test_stream_tool_call_delta_missing_id_and_name(self):
"""LiteLLM may stream tool-call argument deltas with id/name set to None."""
@@ -482,6 +598,38 @@ class TestProcessThinkingContent:
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'The answer is 42.'
def test_remove_leading_think_tag(self):
"""Test removing a leading <think> block when remove_think=True"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = '<think>Let me think...</think> The answer is 42.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'The answer is 42.'
def test_remove_non_leading_think_tag(self):
"""Test removing <think> and its content in the answer body"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'Use <think>example</think> in the document.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Use in the document.'
def test_remove_initial_orphan_think_close(self):
"""Test removing leading reasoning content when only </think> is visible"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'hidden reasoning</think> Visible answer.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Visible answer.'
def test_remove_multiple_think_tags(self):
"""Test removing multiple <think> blocks"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = '<think>hidden</think> Keep <think>example</think>.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Keep .'
def test_preserve_thinking_markers(self):
"""Test preserving thinking markers when remove_think=False"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
@@ -491,6 +639,20 @@ class TestProcessThinkingContent:
assert 'CRETIRE_REASONING_BEGINk' in result
assert 'The answer is 42.' in result
def test_preserve_reasoning_content_when_remove_think_false(self):
"""Test showing separate reasoning_content when remove_think=False"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=False)
assert result == '<think>\nLet me think...\n</think>\nThe answer is 42.'
def test_hide_reasoning_content_when_remove_think_true(self):
"""Test hiding separate reasoning_content when remove_think=True"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=True)
assert result == 'The answer is 42.'
def test_empty_content(self):
"""Test empty content"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
@@ -163,6 +163,51 @@ def test_stream_accumulator_merges_fragmented_tool_call_arguments():
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
def test_stream_accumulator_strips_leading_think_from_tool_round_content():
accumulator = _StreamAccumulator(
msg_sequence=3,
initial_content='I will search for LangBot.',
remove_think=True,
)
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='<thi')) is None
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='nk>hidden')) is None
emitted = accumulator.add(
provider_message.MessageChunk(
role='assistant',
content=' reasoning</think>Here is the answer.',
is_final=True,
)
)
assert emitted is not None
assert emitted.content == 'I will search for LangBot.Here is the answer.'
assert '<think>' not in emitted.content
assert 'hidden reasoning' not in emitted.content
def test_stream_accumulator_strips_initial_orphan_think_close_from_tool_round_content():
accumulator = _StreamAccumulator(
msg_sequence=3,
initial_content='I will search for LangBot.',
remove_think=True,
)
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='hidden reasoning')) is None
emitted = accumulator.add(
provider_message.MessageChunk(
role='assistant',
content=' still hidden</think>Here is the answer.',
is_final=True,
)
)
assert emitted is not None
assert emitted.content == 'I will search for LangBot.Here is the answer.'
assert '</think>' not in emitted.content
assert 'hidden reasoning' not in emitted.content
@pytest.mark.asyncio
async def test_localagent_uses_exec_for_exact_calculation():
provider = RecordingProvider()