diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 52269d705d0..1c034ba1140 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -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 diff --git a/litellm/llms/minimax/chat/transformation.py b/litellm/llms/minimax/chat/transformation.py index 69f228160f6..b9d4a21df4e 100644 --- a/litellm/llms/minimax/chat/transformation.py +++ b/litellm/llms/minimax/chat/transformation.py @@ -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 diff --git a/tests/test_litellm/llms/minimax/chat/test_transformation.py b/tests/test_litellm/llms/minimax/chat/test_transformation.py index 286498830c5..1394d209dac 100644 --- a/tests/test_litellm/llms/minimax/chat/test_transformation.py +++ b/tests/test_litellm/llms/minimax/chat/test_transformation.py @@ -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"""