feat(bedrock): add MiniMax provider support for AWS Bedrock (#4609)
This commit is contained in:
@@ -17,8 +17,9 @@ from mem0.memory.utils import extract_json
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROVIDERS = [
|
PROVIDERS = [
|
||||||
"ai21", "amazon", "anthropic", "cohere", "meta", "mistral", "stability", "writer",
|
"ai21", "amazon", "anthropic", "cohere", "meta", "mistral", "stability", "writer",
|
||||||
"deepseek", "gpt-oss", "perplexity", "snowflake", "titan", "command", "j2", "llama"
|
"deepseek", "gpt-oss", "perplexity", "snowflake", "titan", "command", "j2", "llama",
|
||||||
|
"minimax",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +121,10 @@ class AWSBedrockLLM(LLMBase):
|
|||||||
"""Initialize provider-specific settings and capabilities."""
|
"""Initialize provider-specific settings and capabilities."""
|
||||||
# Determine capabilities based on provider and model
|
# Determine capabilities based on provider and model
|
||||||
self.supports_tools = self.provider in ["anthropic", "cohere", "amazon"]
|
self.supports_tools = self.provider in ["anthropic", "cohere", "amazon"]
|
||||||
|
# MiniMax M2.x is intentionally excluded from supports_tools: tool use for MiniMax
|
||||||
|
# on Amazon Bedrock is only available via the bedrock-mantle (OpenAI-compatible)
|
||||||
|
# endpoint, not via the bedrock-runtime Converse API used by this class.
|
||||||
|
# See: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-minimax-minimax-m2-5.html
|
||||||
self.supports_vision = self.provider in ["anthropic", "amazon", "meta", "mistral"]
|
self.supports_vision = self.provider in ["anthropic", "amazon", "meta", "mistral"]
|
||||||
self.supports_streaming = self.provider in ["anthropic", "cohere", "mistral", "amazon", "meta"]
|
self.supports_streaming = self.provider in ["anthropic", "cohere", "mistral", "amazon", "meta"]
|
||||||
|
|
||||||
@@ -487,7 +492,12 @@ class AWSBedrockLLM(LLMBase):
|
|||||||
return 2000
|
return 2000
|
||||||
|
|
||||||
def _build_inference_config(self) -> Dict[str, Any]:
|
def _build_inference_config(self) -> Dict[str, Any]:
|
||||||
"""Build Converse ``inferenceConfig``. Anthropic allows only one of temperature or topP; we keep temperature and omit topP."""
|
"""Build Converse ``inferenceConfig``.
|
||||||
|
|
||||||
|
Anthropic and MiniMax reasoning models reject requests that include both
|
||||||
|
``temperature`` and ``topP`` simultaneously, so ``topP`` is omitted for
|
||||||
|
those providers even when the user has configured it.
|
||||||
|
"""
|
||||||
inference_config: Dict[str, Any] = {
|
inference_config: Dict[str, Any] = {
|
||||||
"maxTokens": self.model_config.get("max_tokens", self._default_max_tokens_for_converse()),
|
"maxTokens": self.model_config.get("max_tokens", self._default_max_tokens_for_converse()),
|
||||||
"temperature": self.model_config.get("temperature", 0.1),
|
"temperature": self.model_config.get("temperature", 0.1),
|
||||||
@@ -495,8 +505,11 @@ class AWSBedrockLLM(LLMBase):
|
|||||||
|
|
||||||
top_p = self.model_config.get("top_p")
|
top_p = self.model_config.get("top_p")
|
||||||
if top_p is not None:
|
if top_p is not None:
|
||||||
if self.provider == "anthropic":
|
if self.provider in ("anthropic", "minimax"):
|
||||||
logger.debug("Omitting topP for Anthropic Converse (using temperature); top_p=%s", top_p)
|
# Both Anthropic and MiniMax M2.x (reasoning models) raise a
|
||||||
|
# ValidationException when temperature and topP are both present
|
||||||
|
# in inferenceConfig. Omit topP and rely on temperature only.
|
||||||
|
logger.debug("Omitting topP for %s Converse (using temperature); top_p=%s", self.provider, top_p)
|
||||||
else:
|
else:
|
||||||
inference_config["topP"] = top_p
|
inference_config["topP"] = top_p
|
||||||
|
|
||||||
@@ -568,6 +581,39 @@ class AWSBedrockLLM(LLMBase):
|
|||||||
else:
|
else:
|
||||||
return str(response)
|
return str(response)
|
||||||
|
|
||||||
|
elif self.provider == "minimax":
|
||||||
|
# MiniMax models (e.g. minimax.minimax-m2.5) use the Bedrock Converse API.
|
||||||
|
# M2.5 is a reasoning model whose response content array may include a
|
||||||
|
# `reasoningContent` block before the actual `text` block, so we iterate
|
||||||
|
# to find the first block that contains a "text" key.
|
||||||
|
# System messages must be passed via the top-level `system` parameter
|
||||||
|
# (not as a message with role="system") per the Converse API spec.
|
||||||
|
system_parts = []
|
||||||
|
converse_messages = []
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "user")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
content = str(content)
|
||||||
|
if role == "system":
|
||||||
|
system_parts.append(content)
|
||||||
|
else:
|
||||||
|
converse_messages.append({"role": role, "content": [{"text": content}]})
|
||||||
|
if not converse_messages:
|
||||||
|
converse_messages = [{"role": "user", "content": [{"text": ""}]}]
|
||||||
|
converse_params = {
|
||||||
|
"modelId": self.config.model,
|
||||||
|
"messages": converse_messages,
|
||||||
|
"inferenceConfig": self._build_inference_config(),
|
||||||
|
}
|
||||||
|
if system_parts:
|
||||||
|
converse_params["system"] = [{"text": "\n".join(system_parts)}]
|
||||||
|
response = self.client.converse(**converse_params)
|
||||||
|
for block in response["output"]["message"]["content"]:
|
||||||
|
if "text" in block:
|
||||||
|
return block["text"]
|
||||||
|
return ""
|
||||||
|
|
||||||
elif self.provider == "amazon" and "nova" in self.config.model.lower():
|
elif self.provider == "amazon" and "nova" in self.config.model.lower():
|
||||||
# Nova models use the Converse API even without tools
|
# Nova models use the Converse API even without tools
|
||||||
formatted_messages = self._format_messages_amazon(messages)
|
formatted_messages = self._format_messages_amazon(messages)
|
||||||
|
|||||||
@@ -220,6 +220,25 @@ class TestBuildInferenceConfig:
|
|||||||
cfg = llm._build_inference_config()
|
cfg = llm._build_inference_config()
|
||||||
assert cfg["maxTokens"] == 2000
|
assert cfg["maxTokens"] == 2000
|
||||||
|
|
||||||
|
def test_minimax_omits_top_p_when_explicitly_set(self, mock_boto3):
|
||||||
|
# MiniMax M2.x (reasoning model) rejects both temperature and topP simultaneously.
|
||||||
|
# Even when the user explicitly configures top_p, it must be omitted.
|
||||||
|
llm = _make_llm(
|
||||||
|
"minimax.minimax-m2.5",
|
||||||
|
mock_boto3,
|
||||||
|
temperature=0.1,
|
||||||
|
top_p=0.9,
|
||||||
|
)
|
||||||
|
cfg = llm._build_inference_config()
|
||||||
|
assert "temperature" in cfg
|
||||||
|
assert "topP" not in cfg, "topP must be absent for MiniMax reasoning models"
|
||||||
|
|
||||||
|
def test_minimax_only_temperature_by_default(self, mock_boto3):
|
||||||
|
llm = _make_llm("minimax.minimax-m2.5", mock_boto3, temperature=0.1)
|
||||||
|
cfg = llm._build_inference_config()
|
||||||
|
assert cfg["temperature"] == 0.1
|
||||||
|
assert "topP" not in cfg
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# generate_response — Converse API call assertions
|
# generate_response — Converse API call assertions
|
||||||
@@ -328,3 +347,80 @@ class TestGenerateResponseConverse:
|
|||||||
|
|
||||||
_, kwargs = mock_boto3.converse.call_args
|
_, kwargs = mock_boto3.converse.call_args
|
||||||
assert "topP" not in kwargs["inferenceConfig"]
|
assert "topP" not in kwargs["inferenceConfig"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MiniMax provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMiniMaxProvider:
|
||||||
|
"""Tests for MiniMax models via Bedrock Converse API."""
|
||||||
|
|
||||||
|
def test_extract_provider(self):
|
||||||
|
assert extract_provider("minimax.minimax-m2.5") == "minimax"
|
||||||
|
assert extract_provider("minimax.minimax-m2") == "minimax"
|
||||||
|
|
||||||
|
def test_generate_response_text_only(self, mock_boto3):
|
||||||
|
"""Standard response: single text block."""
|
||||||
|
mock_boto3.converse.return_value = _converse_response("Hello!")
|
||||||
|
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
|
||||||
|
|
||||||
|
result = llm.generate_response([{"role": "user", "content": "say hi"}])
|
||||||
|
|
||||||
|
assert result == "Hello!"
|
||||||
|
_, kwargs = mock_boto3.converse.call_args
|
||||||
|
assert kwargs["modelId"] == "minimax.minimax-m2.5"
|
||||||
|
assert kwargs["messages"][0]["role"] == "user"
|
||||||
|
assert kwargs["messages"][0]["content"][0]["text"] == "say hi"
|
||||||
|
|
||||||
|
def test_generate_response_reasoning_model(self, mock_boto3):
|
||||||
|
"""MiniMax M2.5 is a reasoning model: reasoningContent block comes before text."""
|
||||||
|
reasoning_response = {
|
||||||
|
"output": {
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"reasoningContent": {"reasoningText": {"text": "Let me think..."}}},
|
||||||
|
{"text": "Hello!"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_boto3.converse.return_value = reasoning_response
|
||||||
|
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
|
||||||
|
|
||||||
|
result = llm.generate_response([{"role": "user", "content": "say hi"}])
|
||||||
|
|
||||||
|
# Must skip reasoningContent and return the actual text block
|
||||||
|
assert result == "Hello!"
|
||||||
|
|
||||||
|
def test_inference_config(self, mock_boto3):
|
||||||
|
"""inferenceConfig should include maxTokens and temperature; no topP."""
|
||||||
|
mock_boto3.converse.return_value = _converse_response()
|
||||||
|
llm = _make_llm("minimax.minimax-m2.5", mock_boto3, temperature=0.2, max_tokens=512)
|
||||||
|
|
||||||
|
llm.generate_response([{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
_, kwargs = mock_boto3.converse.call_args
|
||||||
|
assert kwargs["inferenceConfig"]["maxTokens"] == 512
|
||||||
|
assert kwargs["inferenceConfig"]["temperature"] == 0.2
|
||||||
|
assert "topP" not in kwargs["inferenceConfig"]
|
||||||
|
|
||||||
|
def test_system_prompt_passed_correctly(self, mock_boto3):
|
||||||
|
"""System messages must be sent via top-level `system` param, not as a message role."""
|
||||||
|
mock_boto3.converse.return_value = _converse_response('{"facts": ["test"]}')
|
||||||
|
llm = _make_llm("minimax.minimax-m2.5", mock_boto3)
|
||||||
|
|
||||||
|
llm.generate_response([
|
||||||
|
{"role": "system", "content": "Return JSON only."},
|
||||||
|
{"role": "user", "content": "Extract facts from: test"},
|
||||||
|
])
|
||||||
|
|
||||||
|
_, kwargs = mock_boto3.converse.call_args
|
||||||
|
# system prompt must be in top-level "system" key
|
||||||
|
assert "system" in kwargs
|
||||||
|
assert kwargs["system"][0]["text"] == "Return JSON only."
|
||||||
|
# messages list must NOT contain a system role entry
|
||||||
|
for msg in kwargs["messages"]:
|
||||||
|
assert msg["role"] != "system"
|
||||||
|
# user message must be present
|
||||||
|
assert kwargs["messages"][0]["role"] == "user"
|
||||||
|
|||||||
Reference in New Issue
Block a user