mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
Merge a0d5a8506a into 3746ba58d7
This commit is contained in:
commit
7fbce19368
3 changed files with 250 additions and 4 deletions
|
|
@ -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 <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( # 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
|
||||
|
|
|
|||
87
tests/llm_translation/test_minimax_transformation.py
Normal file
87
tests/llm_translation/test_minimax_transformation.py
Normal 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
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue