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
@@ -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()