fix(minimax): handle reasoning_details in streaming responses

MiniMax returns reasoning content in delta.reasoning_details (an array
of {"text": "..."} objects) when reasoning_split=True. LiteLLM's
streaming handler only mapped delta.reasoning → delta.reasoning_content,
causing the reasoning_details field to be silently dropped.

- Add MinimaxStreamingHandler that maps reasoning_details → reasoning_content
- Add _concat_reasoning_details helper for DRY concatenation logic
- Add reasoning_details branch to _extract_reasoning_content for non-streaming
- Guard against overwriting existing reasoning_content (precedence safety)

Closes #22392

Signed-off-by: Jay <moonandstar99@yahoo.com>
This commit is contained in:
Jay 2026-05-08 13:54:56 -04:00
parent 144279eb57
commit 50abb0ae4e
3 changed files with 227 additions and 2 deletions

View file

@ -1323,6 +1323,22 @@ def convert_prefix_message_to_non_prefix_messages(
return new_messages
def _concat_reasoning_details(details: list) -> Optional[str]:
"""
Concatenate a reasoning_details array into a single string.
MiniMax returns reasoning as an array of {"text": "..."} objects
when reasoning_split=True is set. This helper joins them.
Returns:
The concatenated text, or None if empty/invalid.
"""
if not isinstance(details, list):
return None
text = "".join(d.get("text", "") for d in details if isinstance(d, dict))
return text or None
def _extract_reasoning_content(message: dict) -> Tuple[Optional[str], Optional[str]]:
"""
Extract reasoning content and main content from a message.
@ -1338,6 +1354,11 @@ def _extract_reasoning_content(message: dict) -> Tuple[Optional[str], Optional[s
return message["reasoning_content"], message_content
elif "reasoning" in message:
return message["reasoning"], message_content
elif "reasoning_details" in message:
text = _concat_reasoning_details(message["reasoning_details"])
if text:
return text, message_content
return None, message_content
elif isinstance(message_content, str):
return _parse_content_for_reasoning(message_content)
return None, message_content

View file

@ -2,10 +2,16 @@
MiniMax OpenAI transformation config - extends OpenAI chat config for MiniMax's OpenAI-compatible API
"""
from typing import List, Optional, Tuple
from typing import Any, Iterator, List, Optional, Tuple, Union
import litellm
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_concat_reasoning_details,
)
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIChatCompletionStreamingHandler,
OpenAIGPTConfig,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
@ -100,3 +106,38 @@ class MinimaxChatConfig(OpenAIGPTConfig):
pass
return base_params + additional_params
def get_model_response_iterator(
self,
streaming_response: Union[Iterator[str], Any],
sync_stream: bool,
json_mode: Optional[bool] = False,
) -> Any:
return MinimaxStreamingHandler(
streaming_response=streaming_response,
sync_stream=sync_stream,
json_mode=json_mode,
)
class MinimaxStreamingHandler(OpenAIChatCompletionStreamingHandler):
"""
Streaming handler for MiniMax that maps reasoning_details to reasoning_content.
MiniMax returns reasoning in delta.reasoning_details (an array of {"text": "..."})
when reasoning_split=True. This handler concatenates the text fields into
delta.reasoning_content for litellm's standard format.
"""
def _map_reasoning_to_reasoning_content(self, choices: list) -> list:
choices = super()._map_reasoning_to_reasoning_content(choices)
for choice in choices:
delta = choice.get("delta", {})
if "reasoning_details" in delta:
details = delta.pop("reasoning_details")
# Don't overwrite if reasoning_content already set (e.g. by parent)
if "reasoning_content" not in delta:
text = _concat_reasoning_details(details)
if text:
delta["reasoning_content"] = text
return choices

View file

@ -111,6 +111,169 @@ def test_minimax_provider_config_manager():
assert isinstance(config, MinimaxChatConfig)
class TestMinimaxReasoningDetails:
"""Test reasoning_details handling in streaming and non-streaming responses."""
def test_streaming_reasoning_details_mapped_to_reasoning_content(self):
"""reasoning_details array in delta should be concatenated into reasoning_content."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [
{
"delta": {
"reasoning_details": [
{"text": "Step 1: "},
{"text": "analyze the problem."},
]
}
}
]
result = handler._map_reasoning_to_reasoning_content(choices)
assert result[0]["delta"]["reasoning_content"] == "Step 1: analyze the problem."
assert "reasoning_details" not in result[0]["delta"]
def test_streaming_empty_reasoning_details_not_mapped(self):
"""Empty reasoning_details array should not produce reasoning_content."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [{"delta": {"reasoning_details": []}}]
result = handler._map_reasoning_to_reasoning_content(choices)
assert "reasoning_content" not in result[0]["delta"]
assert "reasoning_details" not in result[0]["delta"]
def test_streaming_reasoning_details_with_empty_text(self):
"""reasoning_details with empty text fields should not produce reasoning_content."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [{"delta": {"reasoning_details": [{"text": ""}, {"text": ""}]}}]
result = handler._map_reasoning_to_reasoning_content(choices)
assert "reasoning_content" not in result[0]["delta"]
def test_streaming_reasoning_field_still_mapped(self):
"""Parent class mapping of reasoning → reasoning_content should still work."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [{"delta": {"reasoning": "thinking..."}}]
result = handler._map_reasoning_to_reasoning_content(choices)
assert result[0]["delta"]["reasoning_content"] == "thinking..."
assert "reasoning" not in result[0]["delta"]
def test_streaming_content_not_affected(self):
"""Regular content in delta should not be touched."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [
{
"delta": {
"content": "The answer is 4.",
"reasoning_details": [{"text": "2+2=4"}],
}
}
]
result = handler._map_reasoning_to_reasoning_content(choices)
assert result[0]["delta"]["content"] == "The answer is 4."
assert result[0]["delta"]["reasoning_content"] == "2+2=4"
def test_streaming_reasoning_content_takes_precedence_over_details(self):
"""If reasoning_content already set, reasoning_details should not overwrite it."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [
{
"delta": {
"reasoning_content": "already set",
"reasoning_details": [{"text": "should not overwrite"}],
}
}
]
result = handler._map_reasoning_to_reasoning_content(choices)
assert result[0]["delta"]["reasoning_content"] == "already set"
assert "reasoning_details" not in result[0]["delta"]
def test_streaming_reasoning_details_not_a_list(self):
"""Non-list reasoning_details should be popped without setting reasoning_content."""
from litellm.llms.minimax.chat.transformation import MinimaxStreamingHandler
handler = MinimaxStreamingHandler.__new__(MinimaxStreamingHandler)
choices = [{"delta": {"reasoning_details": "not a list"}}]
result = handler._map_reasoning_to_reasoning_content(choices)
assert "reasoning_content" not in result[0]["delta"]
assert "reasoning_details" not in result[0]["delta"]
def test_nonstreaming_reasoning_details_extracted(self):
"""Non-streaming: reasoning_details should be extracted as reasoning_content."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_extract_reasoning_content,
)
message = {
"content": "The answer is 4.",
"reasoning_details": [
{"text": "Let me think: "},
{"text": "2+2=4."},
],
}
reasoning, content = _extract_reasoning_content(message)
assert reasoning == "Let me think: 2+2=4."
assert content == "The answer is 4."
def test_nonstreaming_reasoning_content_takes_precedence(self):
"""reasoning_content field should take precedence over reasoning_details."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_extract_reasoning_content,
)
message = {
"content": "answer",
"reasoning_content": "direct reasoning",
"reasoning_details": [{"text": "detail reasoning"}],
}
reasoning, content = _extract_reasoning_content(message)
assert reasoning == "direct reasoning"
def test_nonstreaming_empty_reasoning_details(self):
"""Empty reasoning_details should return None reasoning."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_extract_reasoning_content,
)
message = {"content": "answer", "reasoning_details": []}
reasoning, content = _extract_reasoning_content(message)
assert reasoning is None
assert content == "answer"
def test_nonstreaming_reasoning_details_not_a_list(self):
"""Non-list reasoning_details in non-streaming should return None."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_extract_reasoning_content,
)
message = {"content": "answer", "reasoning_details": "not a list"}
reasoning, content = _extract_reasoning_content(message)
assert reasoning is None
assert content == "answer"
def test_get_model_response_iterator_returns_minimax_handler(self):
"""MinimaxChatConfig should return MinimaxStreamingHandler."""
from litellm.llms.minimax.chat.transformation import (
MinimaxStreamingHandler,
)
config = MinimaxChatConfig()
handler = config.get_model_response_iterator(
streaming_response=iter([]),
sync_stream=True,
json_mode=False,
)
assert isinstance(handler, MinimaxStreamingHandler)
@pytest.mark.skip(reason="Requires actual MiniMax API key")
def test_minimax_chat_completion_basic():
"""Test basic chat completion with MiniMax OpenAI-compatible API"""