mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
144279eb57
commit
50abb0ae4e
3 changed files with 227 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue