diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b4e758218c5..72ea85dfa33 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1627,14 +1627,7 @@ def _parse_content_for_reasoning( ) if reasoning_match: - reasoning_content = reasoning_match.group(1) - content = reasoning_match.group(2) - if not content.strip(): - # Model's entire answer was inside the think block with - # nothing after it — surface it as content instead of - # silently discarding the model's only real output. - content = reasoning_content - return reasoning_content, content + return reasoning_match.group(1), reasoning_match.group(2) return None, message_text diff --git a/litellm/llms/minimax/chat/transformation.py b/litellm/llms/minimax/chat/transformation.py index c5aa8811f02..5d6b4c35933 100644 --- a/litellm/llms/minimax/chat/transformation.py +++ b/litellm/llms/minimax/chat/transformation.py @@ -2,12 +2,20 @@ MiniMax OpenAI transformation config - extends OpenAI chat config for MiniMax's OpenAI-compatible API """ -from typing import Final +from typing import TYPE_CHECKING, Any, Final import litellm from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam +from litellm.types.utils import ModelResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any class MinimaxChatConfig(OpenAIGPTConfig): @@ -96,3 +104,53 @@ class MinimaxChatConfig(OpenAIGPTConfig): pass return base_params + additional_params + + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + messages: list[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding, + api_key: str | None = None, + json_mode: bool | None = None, + ) -> ModelResponse: + """ + MiniMax M2.7 (reasoning_split unset/false, the default) can return + its entire answer inside ... with nothing trailing + after the closing tag. The shared parser leaves `content` empty in + that case, discarding the model's only real output. + + Scoped to MiniMax only: for other providers using tags, + content left empty after the tag is genuinely empty output, not a + signal to promote reasoning_content into the visible channel — + doing that generically risks leaking hidden reasoning for + adversarial prompts that end right after . MiniMax's docs + confirm the whole-answer-in- shape is expected behavior + for this provider specifically. + """ + response = super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + for choice in response.choices: + message = getattr(choice, "message", None) + if message is None: + continue + reasoning_content = getattr(message, "reasoning_content", None) + if reasoning_content and not (message.content or "").strip(): + message.content = reasoning_content + return response diff --git a/tests/litellm_utils_tests/test_utils.py b/tests/litellm_utils_tests/test_utils.py index ef21796655f..67f2e1ce06d 100644 --- a/tests/litellm_utils_tests/test_utils.py +++ b/tests/litellm_utils_tests/test_utils.py @@ -1043,15 +1043,6 @@ def test_convert_model_response_object(): "The sky is a canvas of blue", ), ("I am a regular response", None, "I am a regular response"), - ( - # Regression for #38197: MiniMax M2.7 puts its entire answer - # inside ... with nothing trailing after the - # closing tag. Fall back to the reasoning content instead of - # silently discarding the model's only real output. - "The answer to 2+2 is 4.", - "The answer to 2+2 is 4.", - "The answer to 2+2 is 4.", - ), ], ) def test_parse_content_for_reasoning(content, expected_reasoning, expected_content): diff --git a/tests/llm_translation/test_minimax_transformation.py b/tests/llm_translation/test_minimax_transformation.py new file mode 100644 index 00000000000..ad3a0e2c94e --- /dev/null +++ b/tests/llm_translation/test_minimax_transformation.py @@ -0,0 +1,87 @@ +""" +Regression test for #38197: MiniMax M2.7 can return its entire answer +inside ... with nothing trailing after the closing tag. +MinimaxChatConfig.transform_response() should fall back to +reasoning_content in that case instead of leaving content empty. + +Scoped to MiniMax only — see the docstring on transform_response for why +this isn't in the shared _parse_content_for_reasoning function. +""" +from unittest.mock import MagicMock + +from litellm.llms.minimax.chat.transformation import MinimaxChatConfig + + +class TestMinimaxTransformResponse: + def test_empty_content_falls_back_to_reasoning_content(self): + config = MinimaxChatConfig() + + fake_message = MagicMock() + fake_message.content = "" + fake_message.reasoning_content = "The answer to 2+2 is 4." + + fake_choice = MagicMock() + fake_choice.message = fake_message + + fake_model_response = MagicMock() + fake_model_response.choices = [fake_choice] + + import litellm.llms.openai.chat.gpt_transformation as parent_module + + original = parent_module.OpenAIGPTConfig.transform_response + parent_module.OpenAIGPTConfig.transform_response = ( + lambda self, **kwargs: fake_model_response + ) + + try: + result = config.transform_response( + model="minimax/MiniMax-M2.7", + raw_response=None, + model_response=fake_model_response, + logging_obj=None, + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.choices[0].message.content == "The answer to 2+2 is 4." + finally: + parent_module.OpenAIGPTConfig.transform_response = original + + def test_normal_content_left_untouched(self): + """Sanity check: when content is already populated, don't overwrite it.""" + config = MinimaxChatConfig() + + fake_message = MagicMock() + fake_message.content = "The answer is 4." + fake_message.reasoning_content = "Let me think about this." + + fake_choice = MagicMock() + fake_choice.message = fake_message + + fake_model_response = MagicMock() + fake_model_response.choices = [fake_choice] + + import litellm.llms.openai.chat.gpt_transformation as parent_module + + original = parent_module.OpenAIGPTConfig.transform_response + parent_module.OpenAIGPTConfig.transform_response = ( + lambda self, **kwargs: fake_model_response + ) + + try: + result = config.transform_response( + model="minimax/MiniMax-M2.7", + raw_response=None, + model_response=fake_model_response, + logging_obj=None, + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.choices[0].message.content == "The answer is 4." + finally: + parent_module.OpenAIGPTConfig.transform_response = original