fix(minimax): scope reasoning_content fallback to MiniMax only, not shared parser

Addresses security review feedback: the earlier fix lived in the shared
_parse_content_for_reasoning() function, which is used by every provider
that emits <think> tags (OpenAI-compatible, Bedrock, Ollama). Promoting
empty content to reasoning_content there risks exposing hidden reasoning
(which may include system instructions or sensitive context) for any
provider where an adversarial prompt can end generation right after
</think>.

Moved the fallback into MinimaxChatConfig.transform_response() instead.
MiniMax's own docs confirm the whole-answer-in-<think> shape is expected
behavior specifically for this provider when reasoning_split is unset,
so the fallback is safe and correct only in this scope.

- Reverted the shared-function change entirely
- Added MinimaxChatConfig.transform_response() override
- New tests in tests/llm_translation/test_minimax_transformation.py
  cover both the fallback case and the already-correct pass-through case
This commit is contained in:
trakshan-mishra 2026-08-25 20:07:06 +05:30
parent 577e94847d
commit ad421e03d2
4 changed files with 147 additions and 18 deletions

View file

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

View file

@ -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 <think>...</think> 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 <think> 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 </think>. MiniMax's docs
confirm the whole-answer-in-<think> 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

View file

@ -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 <think>...</think> with nothing trailing after the
# closing tag. Fall back to the reasoning content instead of
# silently discarding the model's only real output.
"<think>The answer to 2+2 is 4.</think>",
"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):

View file

@ -0,0 +1,87 @@
"""
Regression test for #38197: MiniMax M2.7 can return its entire answer
inside <think>...</think> 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