diff --git a/litellm/llms/minimax/chat/transformation.py b/litellm/llms/minimax/chat/transformation.py index c5aa8811f02..9b651bef699 100644 --- a/litellm/llms/minimax/chat/transformation.py +++ b/litellm/llms/minimax/chat/transformation.py @@ -2,12 +2,26 @@ MiniMax OpenAI transformation config - extends OpenAI chat config for MiniMax's OpenAI-compatible API """ -from typing import Final +from typing import ( + TYPE_CHECKING, + Any, # noqa: TID251 # LiteLLMLoggingObj has no concrete public type; matches OpenAIGPTConfig's own alias + Final, +) + +import httpx 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 +110,51 @@ 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, # mutable-ok: matches parent # pyright: ignore[reportMissingTypeArgument,reportUnknownParameterType] # matches OpenAIGPTConfig.transform_response + messages: list[AllMessageValues], # mutable-ok: matches parent + optional_params: dict, # mutable-ok: matches parent # pyright: ignore[reportMissingTypeArgument,reportUnknownParameterType] # matches OpenAIGPTConfig.transform_response + litellm_params: dict, # mutable-ok: matches parent # pyright: ignore[reportMissingTypeArgument,reportUnknownParameterType] # matches OpenAIGPTConfig.transform_response + encoding, # pyright: ignore[reportAny,reportMissingParameterType] # matches OpenAIGPTConfig.transform_response + 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( # pyright: ignore[reportUnknownMemberType] # super() inherits partially unknown param types from parent + 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 = choice.message + reasoning_content = getattr(message, "reasoning_content", None) # pyright: ignore[reportAny] # Message deletes reasoning_content when None + if reasoning_content and not (message.content or "").strip(): + message.content = reasoning_content + return response 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 diff --git a/tests/test_litellm/llms/minimax/chat/test_transformation.py b/tests/test_litellm/llms/minimax/chat/test_transformation.py index 9d51b556500..f5488fefd58 100644 --- a/tests/test_litellm/llms/minimax/chat/test_transformation.py +++ b/tests/test_litellm/llms/minimax/chat/test_transformation.py @@ -11,6 +11,7 @@ import pytest import litellm from litellm import completion from litellm.llms.minimax.chat.transformation import MinimaxChatConfig +from litellm.types.utils import Choices, Message, ModelResponse def test_minimax_chat_config(): @@ -99,14 +100,110 @@ def test_minimax_provider_config_manager(): from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager - config = ProviderConfigManager.get_provider_chat_config( - model="MiniMax-M2.1", provider=LlmProviders.MINIMAX - ) + config = ProviderConfigManager.get_provider_chat_config(model="MiniMax-M2.1", provider=LlmProviders.MINIMAX) assert config is not None assert isinstance(config, MinimaxChatConfig) +def _build_response_with_reasoning(content: str | None, reasoning_content: str | None): + """Helper: a ModelResponse whose single choice has the given content/reasoning_content.""" + message = Message(content=content, role="assistant", reasoning_content=reasoning_content) + return ModelResponse( + id="test", + choices=[Choices(finish_reason="stop", index=0, message=message)], + model="MiniMax-M2.1", + ) + + +def test_transform_response_promotes_reasoning_content_when_content_empty(): + """Issue #38197: when the model's whole answer sits inside + with nothing trailing, the shared parser leaves content empty. The override + must fall back to reasoning_content so the model's output isn't discarded.""" + config = MinimaxChatConfig() + raw = MagicMock(status_code=200, json=lambda: {}) + original = _build_response_with_reasoning( + content=None, + reasoning_content="The answer to 2+2 is 4.", + ) + + with patch( # test-quality-ok: isolates override's reasoning_content fallback from parent's HTTP/parsing machinery; no injection seam for super().transform_response + "litellm.llms.openai.chat.gpt_transformation.OpenAIGPTConfig.transform_response", + return_value=original, + ): + result = config.transform_response( + model="MiniMax-M2.1", + raw_response=raw, + model_response=ModelResponse(model="MiniMax-M2.1"), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].message.content == "The answer to 2+2 is 4." + + +def test_transform_response_keeps_content_when_already_present(): + """When content is non-empty (answer follows the tag), the override + must not clobber it with reasoning_content.""" + config = MinimaxChatConfig() + raw = MagicMock(status_code=200, json=lambda: {}) + original = _build_response_with_reasoning( + content="The answer is 4.", + reasoning_content="Let me work this out.", + ) + + with patch( # test-quality-ok: isolates override's no-clobber path from parent's HTTP/parsing machinery; no injection seam for super().transform_response + "litellm.llms.openai.chat.gpt_transformation.OpenAIGPTConfig.transform_response", + return_value=original, + ): + result = config.transform_response( + model="MiniMax-M2.1", + raw_response=raw, + model_response=ModelResponse(model="MiniMax-M2.1"), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].message.content == "The answer is 4." + assert result.choices[0].message.reasoning_content == "Let me work this out." + + +def test_transform_response_noop_without_reasoning_content(): + """When reasoning_content is absent/None, content is left untouched.""" + config = MinimaxChatConfig() + raw = MagicMock(status_code=200, json=lambda: {}) + original = _build_response_with_reasoning( + content="plain answer", + reasoning_content=None, + ) + + with patch( # test-quality-ok: isolates override's no-op path from parent's HTTP/parsing machinery; no injection seam for super().transform_response + "litellm.llms.openai.chat.gpt_transformation.OpenAIGPTConfig.transform_response", + return_value=original, + ): + result = config.transform_response( + model="MiniMax-M2.1", + raw_response=raw, + model_response=ModelResponse(model="MiniMax-M2.1"), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].message.content == "plain answer" + + @pytest.mark.skip(reason="Requires actual MiniMax API key") def test_minimax_chat_completion_basic(): """Test basic chat completion with MiniMax OpenAI-compatible API"""