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"""