diff --git a/.env_example b/.env_example
index 95270d7e0..b70da0741 100644
--- a/.env_example
+++ b/.env_example
@@ -194,6 +194,19 @@ OPENAI_VIDEO_MODEL = ${AZURE_OPENAI_VIDEO_MODEL}
OPENAI_VIDEO_UNDERLYING_MODEL = ""
+##################################
+# MINIMAX TARGET SECRETS
+#
+# The below models work with MiniMaxChatTarget - either pass via environment variables
+# or set MINIMAX_API_KEY
+# Supported models: MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed
+###################################
+
+MINIMAX_API_KEY="xxxxx"
+MINIMAX_CHAT_ENDPOINT="https://api.minimax.io/v1"
+MINIMAX_CHAT_MODEL="MiniMax-M2.7"
+
+
##################################
# AML TARGET SECRETS
# The below models work with AzureMLChatTarget - either pass via environment variables
diff --git a/doc/code/targets/0_prompt_targets.md b/doc/code/targets/0_prompt_targets.md
index cfda73d72..e7f915b54 100644
--- a/doc/code/targets/0_prompt_targets.md
+++ b/doc/code/targets/0_prompt_targets.md
@@ -30,6 +30,7 @@ Here are some examples:
| Example | Is `PromptChatTarget`? | Notes |
|-------------------------------------|---------------------------------------|-------------------------------------------------------------------------------------------------|
| **OpenAIChatTarget** (e.g., GPT-4) | **Yes** (`PromptChatTarget`) | Designed for conversational prompts (system messages, conversation history, etc.). |
+| **MiniMaxChatTarget** (e.g., MiniMax-M2.7) | **Yes** (`PromptChatTarget`) | MiniMax AI models via OpenAI-compatible API. Supports multi-turn chat with temperature clamping. |
| **OpenAIImageTarget** | **No** (not a `PromptChatTarget`) | Used for image generation; does not manage conversation history. |
| **HTTPTarget** | **No** (not a `PromptChatTarget`) | Generic HTTP target. Some apps might allow conversation history, but this target doesn't handle it. |
| **AzureBlobStorageTarget** | **No** (not a `PromptChatTarget`) | Used primarily for storage; not for conversation-based AI. |
diff --git a/pyrit/prompt_target/__init__.py b/pyrit/prompt_target/__init__.py
index 05af2d67d..855114ae6 100644
--- a/pyrit/prompt_target/__init__.py
+++ b/pyrit/prompt_target/__init__.py
@@ -24,6 +24,7 @@
from pyrit.prompt_target.http_target.httpx_api_target import HTTPXAPITarget
from pyrit.prompt_target.hugging_face.hugging_face_chat_target import HuggingFaceChatTarget
from pyrit.prompt_target.hugging_face.hugging_face_endpoint_target import HuggingFaceEndpointTarget
+from pyrit.prompt_target.minimax.minimax_chat_target import MiniMaxChatTarget
from pyrit.prompt_target.openai.openai_chat_audio_config import OpenAIChatAudioConfig
from pyrit.prompt_target.openai.openai_chat_target import OpenAIChatTarget
from pyrit.prompt_target.openai.openai_completion_target import OpenAICompletionTarget
@@ -53,6 +54,7 @@
"HuggingFaceChatTarget",
"HuggingFaceEndpointTarget",
"limit_requests_per_minute",
+ "MiniMaxChatTarget",
"OpenAICompletionTarget",
"OpenAIChatAudioConfig",
"OpenAIChatTarget",
diff --git a/pyrit/prompt_target/minimax/__init__.py b/pyrit/prompt_target/minimax/__init__.py
new file mode 100644
index 000000000..1eaddd875
--- /dev/null
+++ b/pyrit/prompt_target/minimax/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from pyrit.prompt_target.minimax.minimax_chat_target import MiniMaxChatTarget
+
+__all__ = ["MiniMaxChatTarget"]
diff --git a/pyrit/prompt_target/minimax/minimax_chat_target.py b/pyrit/prompt_target/minimax/minimax_chat_target.py
new file mode 100644
index 000000000..a1da0ed12
--- /dev/null
+++ b/pyrit/prompt_target/minimax/minimax_chat_target.py
@@ -0,0 +1,297 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import json
+import logging
+from collections.abc import MutableSequence
+from typing import Any, Optional
+
+from openai import AsyncOpenAI, BadRequestError, RateLimitError
+from openai._exceptions import APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError
+
+from pyrit.common import default_values
+from pyrit.exceptions import EmptyResponseException, PyritException, pyrit_target_retry
+from pyrit.exceptions.exception_classes import RateLimitException, handle_bad_request_exception
+from pyrit.identifiers import ComponentIdentifier
+from pyrit.models import ChatMessage, Message, MessagePiece, construct_response_from_request
+from pyrit.models.json_response_config import _JsonResponseConfig
+from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget
+from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
+from pyrit.prompt_target.common.utils import limit_requests_per_minute, validate_temperature, validate_top_p
+
+logger = logging.getLogger(__name__)
+
+# MiniMax temperature range is [0, 1] (unlike OpenAI's [0, 2])
+_MINIMAX_MAX_TEMPERATURE = 1.0
+
+
+class MiniMaxChatTarget(PromptChatTarget):
+ """
+ Prompt target for MiniMax AI chat models via the OpenAI-compatible API.
+
+ MiniMax provides large language models accessible through an OpenAI-compatible
+ chat completions endpoint at https://api.minimax.io/v1.
+
+ Supported models:
+ - MiniMax-M2.7: Latest model with 1M token context
+ - MiniMax-M2.7-highspeed: Faster variant of M2.7
+ - MiniMax-M2.5: Previous generation with 204K context
+ - MiniMax-M2.5-highspeed: Faster variant of M2.5
+
+ Args:
+ api_key (str): The API key for the MiniMax API.
+ endpoint (str): The endpoint URL (default: https://api.minimax.io/v1).
+ model_name (str): The model name (default: MiniMax-M2.7).
+ temperature (float): The temperature for the completion (0.0-1.0).
+ max_completion_tokens (int): Maximum number of tokens to generate.
+ top_p (float): The nucleus sampling probability.
+ max_requests_per_minute (int): Rate limit for requests per minute.
+
+ Example usage:
+
+ >>> from pyrit.prompt_target import MiniMaxChatTarget
+ >>> target = MiniMaxChatTarget(
+ ... api_key="your-minimax-api-key",
+ ... model_name="MiniMax-M2.7",
+ ... )
+ """
+
+ _DEFAULT_CAPABILITIES: TargetCapabilities = TargetCapabilities(
+ supports_multi_turn=True,
+ supports_json_output=True,
+ supports_multi_message_pieces=False,
+ )
+
+ MINIMAX_CHAT_MODEL_ENV: str = "MINIMAX_CHAT_MODEL"
+ MINIMAX_CHAT_ENDPOINT_ENV: str = "MINIMAX_CHAT_ENDPOINT"
+ MINIMAX_CHAT_KEY_ENV: str = "MINIMAX_API_KEY"
+
+ _DEFAULT_ENDPOINT: str = "https://api.minimax.io/v1"
+ _DEFAULT_MODEL: str = "MiniMax-M2.7"
+
+ def __init__(
+ self,
+ *,
+ api_key: Optional[str] = None,
+ endpoint: Optional[str] = None,
+ model_name: Optional[str] = None,
+ temperature: Optional[float] = None,
+ max_completion_tokens: Optional[int] = None,
+ top_p: Optional[float] = None,
+ max_requests_per_minute: Optional[int] = None,
+ custom_capabilities: Optional[TargetCapabilities] = None,
+ httpx_client_kwargs: Optional[dict[str, Any]] = None,
+ ) -> None:
+ # Resolve configuration from parameters or environment variables
+ resolved_api_key = default_values.get_required_value(
+ env_var_name=self.MINIMAX_CHAT_KEY_ENV, passed_value=api_key
+ )
+ resolved_endpoint = default_values.get_non_required_value(
+ env_var_name=self.MINIMAX_CHAT_ENDPOINT_ENV, passed_value=endpoint
+ ) or self._DEFAULT_ENDPOINT
+ resolved_model = default_values.get_non_required_value(
+ env_var_name=self.MINIMAX_CHAT_MODEL_ENV, passed_value=model_name
+ ) or self._DEFAULT_MODEL
+
+ # Validate and clamp temperature to MiniMax range [0, 1]
+ if temperature is not None:
+ validate_temperature(temperature)
+ if temperature > _MINIMAX_MAX_TEMPERATURE:
+ logger.warning(
+ f"MiniMax supports temperature in [0, 1]. "
+ f"Clamping {temperature} to {_MINIMAX_MAX_TEMPERATURE}."
+ )
+ temperature = _MINIMAX_MAX_TEMPERATURE
+
+ validate_top_p(top_p)
+
+ effective_capabilities = custom_capabilities or type(self)._DEFAULT_CAPABILITIES
+
+ super().__init__(
+ max_requests_per_minute=max_requests_per_minute,
+ endpoint=resolved_endpoint,
+ model_name=resolved_model,
+ custom_capabilities=effective_capabilities,
+ )
+
+ self._temperature = temperature
+ self._top_p = top_p
+ self._max_completion_tokens = max_completion_tokens
+ self._api_key = resolved_api_key
+
+ # Initialize the OpenAI-compatible async client for MiniMax
+ httpx_kwargs = httpx_client_kwargs or {}
+ self._async_client = AsyncOpenAI(
+ base_url=resolved_endpoint,
+ api_key=resolved_api_key,
+ **httpx_kwargs,
+ )
+
+ def _build_identifier(self) -> ComponentIdentifier:
+ return self._create_identifier(
+ params={
+ "temperature": self._temperature,
+ "top_p": self._top_p,
+ "max_completion_tokens": self._max_completion_tokens,
+ },
+ )
+
+ @limit_requests_per_minute
+ @pyrit_target_retry
+ async def send_prompt_async(self, *, message: Message) -> list[Message]:
+ """
+ Sends a chat prompt to the MiniMax API and returns the response.
+
+ Args:
+ message: The message to send.
+
+ Returns:
+ A list containing the response Message.
+ """
+ self._validate_request(message=message)
+
+ message_piece: MessagePiece = message.message_pieces[0]
+ json_config = self._get_json_response_config(message_piece=message_piece)
+
+ # Get conversation history and append the current message
+ conversation = self._memory.get_conversation(conversation_id=message_piece.conversation_id)
+ conversation.append(message)
+
+ logger.info(f"Sending the following prompt to MiniMax: {message}")
+
+ body = self._construct_request_body(conversation=conversation, json_config=json_config)
+
+ try:
+ response = await self._async_client.chat.completions.create(**body)
+
+ # Validate response
+ if not hasattr(response, "choices") or not response.choices:
+ raise PyritException(message="No choices returned in the MiniMax completion response.")
+
+ choice = response.choices[0]
+ finish_reason = choice.finish_reason
+
+ valid_finish_reasons = ["stop", "length", "tool_calls"]
+ if finish_reason not in valid_finish_reasons:
+ raise PyritException(
+ message=f"Unknown finish_reason '{finish_reason}' from MiniMax response: "
+ f"{response.model_dump_json()}"
+ )
+
+ content = choice.message.content
+ if not content:
+ raise EmptyResponseException(message="MiniMax returned an empty response.")
+
+ # Strip thinking tags that MiniMax M2.5+ models may include
+ content = self._strip_thinking_tags(content)
+
+ result_message = construct_response_from_request(
+ request=message_piece,
+ response_text_pieces=[content],
+ response_type="text",
+ )
+
+ # Capture token usage metadata
+ if hasattr(response, "usage") and response.usage:
+ result_message.message_pieces[0].prompt_metadata["token_usage_model_name"] = getattr(
+ response, "model", "unknown"
+ )
+ result_message.message_pieces[0].prompt_metadata["token_usage_prompt_tokens"] = getattr(
+ response.usage, "prompt_tokens", 0
+ )
+ result_message.message_pieces[0].prompt_metadata["token_usage_completion_tokens"] = getattr(
+ response.usage, "completion_tokens", 0
+ )
+ result_message.message_pieces[0].prompt_metadata["token_usage_total_tokens"] = getattr(
+ response.usage, "total_tokens", 0
+ )
+
+ return [result_message]
+
+ except BadRequestError as e:
+ logger.warning(f"MiniMax BadRequestError: {e}")
+ return [
+ handle_bad_request_exception(
+ response_text=str(e),
+ request=message_piece,
+ error_code=400,
+ is_content_filter=False,
+ )
+ ]
+ except RateLimitError as e:
+ logger.warning(f"MiniMax RateLimitError: {e}")
+ raise RateLimitException from None
+ except APIStatusError as e:
+ if getattr(e, "status_code", None) == 429:
+ logger.warning(f"MiniMax 429 rate limit via APIStatusError: {e}")
+ raise RateLimitException from None
+ logger.exception(f"MiniMax APIStatusError: {e}")
+ raise
+ except (APITimeoutError, APIConnectionError) as e:
+ logger.warning(f"MiniMax transient error ({e.__class__.__name__}): {e}")
+ raise
+ except AuthenticationError as e:
+ logger.error(f"MiniMax authentication error: {e}")
+ raise
+
+ def _construct_request_body(
+ self, *, conversation: MutableSequence[Message], json_config: _JsonResponseConfig
+ ) -> dict[str, Any]:
+ """Build the request body for the MiniMax chat completions API."""
+ messages = self._build_chat_messages(conversation)
+ response_format = self._build_response_format(json_config)
+
+ body: dict[str, Any] = {
+ "model": self._model_name,
+ "messages": messages,
+ "stream": False,
+ }
+
+ if self._temperature is not None:
+ body["temperature"] = self._temperature
+ if self._top_p is not None:
+ body["top_p"] = self._top_p
+ if self._max_completion_tokens is not None:
+ body["max_tokens"] = self._max_completion_tokens
+ if response_format is not None:
+ body["response_format"] = response_format
+
+ return body
+
+ def _build_chat_messages(self, conversation: MutableSequence[Message]) -> list[dict[str, Any]]:
+ """Convert conversation Messages to the chat API format."""
+ chat_messages: list[dict[str, Any]] = []
+ for message in conversation:
+ if len(message.message_pieces) != 1:
+ raise ValueError("MiniMaxChatTarget only supports single-piece text messages.")
+
+ piece = message.message_pieces[0]
+ if piece.converted_value_data_type not in ("text", "error"):
+ raise ValueError(
+ f"MiniMaxChatTarget only supports text data types. "
+ f"Received: {piece.converted_value_data_type}."
+ )
+
+ chat_message = ChatMessage(role=piece.api_role, content=piece.converted_value)
+ chat_messages.append(chat_message.model_dump(exclude_none=True))
+
+ return chat_messages
+
+ def _build_response_format(self, json_config: _JsonResponseConfig) -> Optional[dict[str, Any]]:
+ """Build the response_format parameter for JSON mode."""
+ if not json_config.enabled:
+ return None
+ # MiniMax supports json_object mode but not json_schema
+ return {"type": "json_object"}
+
+ @staticmethod
+ def _strip_thinking_tags(content: str) -> str:
+ """
+ Strip ... tags from MiniMax M2.5+ model responses.
+
+ Some MiniMax models include internal reasoning in tags.
+ These should be stripped from the final response.
+ """
+ import re
+
+ return re.sub(r".*?\s*", "", content, flags=re.DOTALL).strip()
diff --git a/tests/integration/targets/test_minimax_chat_target_integration.py b/tests/integration/targets/test_minimax_chat_target_integration.py
new file mode 100644
index 000000000..8e9c023e6
--- /dev/null
+++ b/tests/integration/targets/test_minimax_chat_target_integration.py
@@ -0,0 +1,142 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Integration tests for MiniMaxChatTarget.
+
+These tests verify:
+- Basic chat completion with MiniMax API
+- Multi-turn conversation support
+- JSON mode output
+
+Requirements:
+ - MINIMAX_API_KEY: A valid MiniMax API key
+"""
+
+import os
+import uuid
+
+import pytest
+
+from pyrit.models import Message, MessagePiece
+from pyrit.prompt_target.minimax.minimax_chat_target import MiniMaxChatTarget
+
+
+@pytest.fixture()
+def minimax_chat_args():
+ """
+ Fixture for MiniMax chat model configuration.
+
+ Requires:
+ - MINIMAX_API_KEY: The MiniMax API key
+ """
+ api_key = os.environ.get("MINIMAX_API_KEY")
+
+ if not api_key:
+ pytest.skip("MINIMAX_API_KEY must be set for MiniMax integration tests")
+
+ return {
+ "api_key": api_key,
+ "model_name": "MiniMax-M2.7",
+ "endpoint": "https://api.minimax.io/v1",
+ }
+
+
+@pytest.mark.asyncio
+async def test_minimax_chat_basic_completion(sqlite_instance, minimax_chat_args):
+ """Test basic chat completion with MiniMax."""
+ target = MiniMaxChatTarget(**minimax_chat_args, temperature=0.1)
+
+ conversation_id = str(uuid.uuid4())
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id=conversation_id,
+ original_value="What is 2 + 2? Reply with just the number.",
+ converted_value="What is 2 + 2? Reply with just the number.",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+ ]
+ )
+
+ result = await target.send_prompt_async(message=message)
+
+ assert len(result) == 1
+ assert len(result[0].message_pieces) == 1
+ response_text = result[0].get_value()
+ assert "4" in response_text
+
+
+@pytest.mark.asyncio
+async def test_minimax_chat_multi_turn(sqlite_instance, minimax_chat_args):
+ """Test multi-turn conversation with MiniMax."""
+ target = MiniMaxChatTarget(**minimax_chat_args, temperature=0.1)
+
+ conversation_id = str(uuid.uuid4())
+
+ # First turn
+ message1 = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id=conversation_id,
+ original_value="My name is Alice. Remember it.",
+ converted_value="My name is Alice. Remember it.",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+ ]
+ )
+
+ result1 = await target.send_prompt_async(message=message1)
+ assert len(result1) == 1
+
+ # Second turn - test that conversation context is maintained
+ message2 = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id=conversation_id,
+ original_value="What is my name?",
+ converted_value="What is my name?",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+ ]
+ )
+
+ result2 = await target.send_prompt_async(message=message2)
+ assert len(result2) == 1
+ assert "Alice" in result2[0].get_value()
+
+
+@pytest.mark.asyncio
+async def test_minimax_chat_json_mode(sqlite_instance, minimax_chat_args):
+ """Test JSON mode output with MiniMax."""
+ target = MiniMaxChatTarget(**minimax_chat_args, temperature=0.1)
+
+ conversation_id = str(uuid.uuid4())
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id=conversation_id,
+ original_value='Return a JSON object with a "color" key set to "blue".',
+ converted_value='Return a JSON object with a "color" key set to "blue".',
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ prompt_metadata={"response_format": "json"},
+ )
+ ]
+ )
+
+ result = await target.send_prompt_async(message=message)
+ assert len(result) == 1
+
+ import json
+
+ response_text = result[0].get_value()
+ parsed = json.loads(response_text)
+ assert parsed.get("color") == "blue"
diff --git a/tests/unit/target/test_minimax_chat_target.py b/tests/unit/target/test_minimax_chat_target.py
new file mode 100644
index 000000000..d1055db7f
--- /dev/null
+++ b/tests/unit/target/test_minimax_chat_target.py
@@ -0,0 +1,632 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import json
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+from openai import APIStatusError, BadRequestError, RateLimitError
+from openai.types.chat import ChatCompletion
+
+from pyrit.exceptions.exception_classes import EmptyResponseException, PyritException, RateLimitException
+from pyrit.memory.memory_interface import MemoryInterface
+from pyrit.models import Message, MessagePiece
+from pyrit.models.json_response_config import _JsonResponseConfig
+from pyrit.prompt_target import PromptChatTarget
+from pyrit.prompt_target.minimax.minimax_chat_target import MiniMaxChatTarget
+
+
+def create_mock_completion(content: str = "hi", finish_reason: str = "stop"):
+ """Helper to create a mock OpenAI-compatible completion response."""
+ mock_completion = MagicMock(spec=ChatCompletion)
+ mock_completion.choices = [MagicMock()]
+ mock_completion.choices[0].finish_reason = finish_reason
+ mock_completion.choices[0].message.content = content
+ mock_completion.choices[0].message.tool_calls = None
+ mock_completion.model_dump_json.return_value = json.dumps(
+ {"choices": [{"finish_reason": finish_reason, "message": {"content": content}}]}
+ )
+ mock_completion.usage = None
+ return mock_completion
+
+
+@pytest.fixture
+def dummy_text_message_piece() -> MessagePiece:
+ return MessagePiece(
+ role="user",
+ conversation_id="dummy_convo",
+ original_value="dummy text",
+ converted_value="dummy text",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+
+
+@pytest.fixture
+def target(patch_central_database) -> MiniMaxChatTarget:
+ return MiniMaxChatTarget(
+ api_key="mock-minimax-api-key",
+ model_name="MiniMax-M2.7",
+ endpoint="https://api.minimax.io/v1",
+ )
+
+
+# ============================================================================
+# Initialization Tests
+# ============================================================================
+
+
+def test_init_with_no_api_key_raises():
+ """Test that initialization without API key raises ValueError."""
+ with patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError):
+ MiniMaxChatTarget()
+
+
+def test_init_with_api_key_from_env(patch_central_database):
+ """Test initialization with API key from environment variable."""
+ with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-api-key"}, clear=False):
+ target = MiniMaxChatTarget()
+ assert target._api_key == "env-api-key"
+ assert target._model_name == "MiniMax-M2.7" # default
+
+
+def test_init_with_explicit_params(patch_central_database):
+ """Test initialization with explicit parameters."""
+ target = MiniMaxChatTarget(
+ api_key="test-key",
+ model_name="MiniMax-M2.5",
+ endpoint="https://custom.minimax.io/v1",
+ temperature=0.5,
+ top_p=0.9,
+ max_completion_tokens=1024,
+ )
+ assert target._api_key == "test-key"
+ assert target._model_name == "MiniMax-M2.5"
+ assert target._endpoint == "https://custom.minimax.io/v1"
+ assert target._temperature == 0.5
+ assert target._top_p == 0.9
+ assert target._max_completion_tokens == 1024
+
+
+def test_init_default_endpoint(patch_central_database):
+ """Test that default endpoint is set when not provided."""
+ target = MiniMaxChatTarget(api_key="test-key")
+ assert target._endpoint == "https://api.minimax.io/v1"
+
+
+def test_init_default_model(patch_central_database):
+ """Test that default model is set when not provided."""
+ target = MiniMaxChatTarget(api_key="test-key")
+ assert target._model_name == "MiniMax-M2.7"
+
+
+def test_inheritance_from_prompt_chat_target(target: MiniMaxChatTarget):
+ """Test that MiniMaxChatTarget properly inherits from PromptChatTarget."""
+ assert isinstance(target, PromptChatTarget)
+
+
+# ============================================================================
+# Temperature Validation Tests
+# ============================================================================
+
+
+def test_invalid_temperature_raises(patch_central_database):
+ """Test that temperature outside [0, 2] raises PyritException."""
+ with pytest.raises(PyritException, match="temperature must be between 0 and 2"):
+ MiniMaxChatTarget(api_key="test-key", temperature=-0.1)
+
+ with pytest.raises(PyritException, match="temperature must be between 0 and 2"):
+ MiniMaxChatTarget(api_key="test-key", temperature=2.1)
+
+
+def test_temperature_clamped_to_minimax_max(patch_central_database, caplog):
+ """Test that temperature > 1.0 is clamped to 1.0 for MiniMax."""
+ import logging
+
+ with caplog.at_level(logging.WARNING):
+ target = MiniMaxChatTarget(api_key="test-key", temperature=1.5)
+
+ assert target._temperature == 1.0
+ assert any("Clamping" in record.message for record in caplog.records)
+
+
+def test_temperature_zero_accepted(patch_central_database):
+ """Test that temperature=0 is accepted."""
+ target = MiniMaxChatTarget(api_key="test-key", temperature=0.0)
+ assert target._temperature == 0.0
+
+
+def test_temperature_one_accepted(patch_central_database):
+ """Test that temperature=1.0 is accepted without clamping."""
+ target = MiniMaxChatTarget(api_key="test-key", temperature=1.0)
+ assert target._temperature == 1.0
+
+
+# ============================================================================
+# Top-p Validation Tests
+# ============================================================================
+
+
+def test_invalid_top_p_raises(patch_central_database):
+ """Test that invalid top_p values raise PyritException."""
+ with pytest.raises(PyritException, match="top_p must be between 0 and 1"):
+ MiniMaxChatTarget(api_key="test-key", top_p=-0.1)
+
+ with pytest.raises(PyritException, match="top_p must be between 0 and 1"):
+ MiniMaxChatTarget(api_key="test-key", top_p=1.1)
+
+
+# ============================================================================
+# Request Body Construction Tests
+# ============================================================================
+
+
+def test_construct_request_body_minimal(target: MiniMaxChatTarget, dummy_text_message_piece: MessagePiece):
+ """Test minimal request body construction."""
+ request = Message(message_pieces=[dummy_text_message_piece])
+ jrc = _JsonResponseConfig.from_metadata(metadata=None)
+
+ body = target._construct_request_body(conversation=[request], json_config=jrc)
+
+ assert body["model"] == "MiniMax-M2.7"
+ assert body["messages"][0]["content"] == "dummy text"
+ assert body["stream"] is False
+ assert "temperature" not in body
+ assert "top_p" not in body
+ assert "max_tokens" not in body
+ assert "response_format" not in body
+
+
+def test_construct_request_body_with_params(patch_central_database, dummy_text_message_piece: MessagePiece):
+ """Test request body includes configured parameters."""
+ target = MiniMaxChatTarget(
+ api_key="test-key",
+ temperature=0.7,
+ top_p=0.9,
+ max_completion_tokens=512,
+ )
+
+ request = Message(message_pieces=[dummy_text_message_piece])
+ jrc = _JsonResponseConfig.from_metadata(metadata=None)
+
+ body = target._construct_request_body(conversation=[request], json_config=jrc)
+
+ assert body["temperature"] == 0.7
+ assert body["top_p"] == 0.9
+ assert body["max_tokens"] == 512
+
+
+def test_construct_request_body_json_mode(target: MiniMaxChatTarget, dummy_text_message_piece: MessagePiece):
+ """Test request body with JSON response format."""
+ request = Message(message_pieces=[dummy_text_message_piece])
+ jrc = _JsonResponseConfig.from_metadata(metadata={"response_format": "json"})
+
+ body = target._construct_request_body(conversation=[request], json_config=jrc)
+
+ assert body["response_format"] == {"type": "json_object"}
+
+
+# ============================================================================
+# Chat Message Building Tests
+# ============================================================================
+
+
+def test_build_chat_messages_single(target: MiniMaxChatTarget, dummy_text_message_piece: MessagePiece):
+ """Test building chat messages from a single message."""
+ messages = target._build_chat_messages([Message(message_pieces=[dummy_text_message_piece])])
+ assert len(messages) == 1
+ assert messages[0]["role"] == "user"
+ assert messages[0]["content"] == "dummy text"
+
+
+def test_build_chat_messages_multi_turn(target: MiniMaxChatTarget):
+ """Test building chat messages from a multi-turn conversation."""
+ conversation = [
+ Message(
+ message_pieces=[
+ MessagePiece(
+ role="system",
+ conversation_id="conv1",
+ original_value="You are a helpful assistant.",
+ converted_value="You are a helpful assistant.",
+ converted_value_data_type="text",
+ )
+ ]
+ ),
+ Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id="conv1",
+ original_value="Hello",
+ converted_value="Hello",
+ converted_value_data_type="text",
+ )
+ ]
+ ),
+ Message(
+ message_pieces=[
+ MessagePiece(
+ role="assistant",
+ conversation_id="conv1",
+ original_value="Hi there!",
+ converted_value="Hi there!",
+ converted_value_data_type="text",
+ )
+ ]
+ ),
+ ]
+
+ messages = target._build_chat_messages(conversation)
+ assert len(messages) == 3
+ assert messages[0]["role"] == "system"
+ assert messages[1]["role"] == "user"
+ assert messages[2]["role"] == "assistant"
+
+
+def test_build_chat_messages_rejects_multi_piece(target: MiniMaxChatTarget):
+ """Test that multi-piece messages are rejected."""
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id="conv1",
+ original_value="text",
+ converted_value="text",
+ converted_value_data_type="text",
+ ),
+ MessagePiece(
+ role="user",
+ conversation_id="conv1",
+ original_value="more text",
+ converted_value="more text",
+ converted_value_data_type="text",
+ ),
+ ]
+ )
+
+ with pytest.raises(ValueError, match="single-piece text messages"):
+ target._build_chat_messages([message])
+
+
+def test_build_chat_messages_rejects_non_text(target: MiniMaxChatTarget):
+ """Test that non-text data types are rejected."""
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id="conv1",
+ original_value="/path/to/image.jpg",
+ converted_value="/path/to/image.jpg",
+ original_value_data_type="image_path",
+ converted_value_data_type="image_path",
+ )
+ ]
+ )
+
+ with pytest.raises(ValueError, match="only supports text"):
+ target._build_chat_messages([message])
+
+
+# ============================================================================
+# Think Tag Stripping Tests
+# ============================================================================
+
+
+def test_strip_thinking_tags_with_tags():
+ """Test stripping tags from response."""
+ content = "Let me think about this...The answer is 42."
+ result = MiniMaxChatTarget._strip_thinking_tags(content)
+ assert result == "The answer is 42."
+
+
+def test_strip_thinking_tags_without_tags():
+ """Test that content without think tags is unchanged."""
+ content = "The answer is 42."
+ result = MiniMaxChatTarget._strip_thinking_tags(content)
+ assert result == "The answer is 42."
+
+
+def test_strip_thinking_tags_multiline():
+ """Test stripping multiline think tags."""
+ content = "\nStep 1: Consider this\nStep 2: Do that\n\nFinal answer."
+ result = MiniMaxChatTarget._strip_thinking_tags(content)
+ assert result == "Final answer."
+
+
+def test_strip_thinking_tags_empty_think():
+ """Test stripping empty think tags."""
+ content = "Response here."
+ result = MiniMaxChatTarget._strip_thinking_tags(content)
+ assert result == "Response here."
+
+
+# ============================================================================
+# Send Prompt Tests
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_success(target: MiniMaxChatTarget):
+ """Test successful prompt sending."""
+ mock_completion = create_mock_completion(content="Hello from MiniMax!")
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id="test-conv",
+ original_value="Hello",
+ converted_value="Hello",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+ ]
+ )
+
+ result = await target.send_prompt_async(message=message)
+
+ assert len(result) == 1
+ assert len(result[0].message_pieces) == 1
+ assert result[0].get_value() == "Hello from MiniMax!"
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_strips_thinking_tags(target: MiniMaxChatTarget):
+ """Test that thinking tags are stripped from response."""
+ mock_completion = create_mock_completion(
+ content="Internal reasoning hereThe actual answer."
+ )
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+
+ message = Message(
+ message_pieces=[
+ MessagePiece(
+ role="user",
+ conversation_id="test-conv",
+ original_value="Question",
+ converted_value="Question",
+ original_value_data_type="text",
+ converted_value_data_type="text",
+ )
+ ]
+ )
+
+ result = await target.send_prompt_async(message=message)
+
+ assert result[0].get_value() == "The actual answer."
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_empty_response(target: MiniMaxChatTarget):
+ """Test that empty response raises EmptyResponseException."""
+ mock_completion = create_mock_completion(content="")
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+ target._memory = MagicMock(MemoryInterface)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(EmptyResponseException):
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_rate_limit(target: MiniMaxChatTarget):
+ """Test that RateLimitError raises RateLimitException."""
+ mock_request = httpx.Request("POST", "https://api.minimax.io/v1/chat/completions")
+ mock_response = httpx.Response(429, text="Rate Limit Reached", request=mock_request)
+ side_effect = RateLimitError("Rate Limit Reached", response=mock_response, body=None)
+
+ target._async_client.chat.completions.create = AsyncMock(side_effect=side_effect)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(RateLimitException):
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_bad_request(target: MiniMaxChatTarget):
+ """Test that BadRequestError is handled and returns error response."""
+ mock_request = httpx.Request("POST", "https://api.minimax.io/v1/chat/completions")
+ error_body = {"error": {"message": "Invalid request", "code": "bad_request"}}
+ mock_response = httpx.Response(400, text=json.dumps(error_body), request=mock_request)
+ side_effect = BadRequestError("Bad Request", response=mock_response, body=error_body)
+
+ target._async_client.chat.completions.create = AsyncMock(side_effect=side_effect)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ # Non-content-filter BadRequestError should be re-raised
+ with pytest.raises(Exception): # noqa: B017
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_api_status_error_429(target: MiniMaxChatTarget):
+ """Test that APIStatusError with 429 raises RateLimitException."""
+ mock_response = MagicMock()
+ mock_response.status_code = 429
+ mock_response.text = "Too many requests"
+ mock_response.headers = {}
+
+ api_error = APIStatusError("Too many requests", response=mock_response, body={})
+ api_error.status_code = 429
+
+ target._async_client.chat.completions.create = AsyncMock(side_effect=api_error)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(RateLimitException):
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_api_status_error_500(target: MiniMaxChatTarget):
+ """Test that APIStatusError with 500 is re-raised."""
+ mock_response = MagicMock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal Server Error"
+ mock_response.headers = {}
+
+ api_error = APIStatusError("Internal Server Error", response=mock_response, body={})
+ api_error.status_code = 500
+
+ target._async_client.chat.completions.create = AsyncMock(side_effect=api_error)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(APIStatusError):
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_unknown_finish_reason(target: MiniMaxChatTarget):
+ """Test that unknown finish_reason raises PyritException."""
+ mock_completion = create_mock_completion(content="test", finish_reason="unexpected_reason")
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+ target._memory = MagicMock(MemoryInterface)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(PyritException, match="Unknown finish_reason"):
+ await target.send_prompt_async(message=message)
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_async_no_choices(target: MiniMaxChatTarget):
+ """Test that response with no choices raises PyritException."""
+ mock_completion = create_mock_completion(content="test")
+ mock_completion.choices = []
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+ target._memory = MagicMock(MemoryInterface)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ with pytest.raises(PyritException, match="No choices"):
+ await target.send_prompt_async(message=message)
+
+
+# ============================================================================
+# Token Usage Tests
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_captures_token_usage(target: MiniMaxChatTarget):
+ """Test that token usage metadata is captured from API response."""
+ mock_completion = create_mock_completion(content="Response with tokens")
+ mock_completion.model = "MiniMax-M2.7"
+ mock_completion.usage = MagicMock()
+ mock_completion.usage.prompt_tokens = 15
+ mock_completion.usage.completion_tokens = 25
+ mock_completion.usage.total_tokens = 40
+
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ result = await target.send_prompt_async(message=message)
+ piece = result[0].message_pieces[0]
+
+ assert piece.prompt_metadata["token_usage_model_name"] == "MiniMax-M2.7"
+ assert piece.prompt_metadata["token_usage_prompt_tokens"] == 15
+ assert piece.prompt_metadata["token_usage_completion_tokens"] == 25
+ assert piece.prompt_metadata["token_usage_total_tokens"] == 40
+
+
+@pytest.mark.asyncio
+async def test_send_prompt_no_usage_no_metadata(target: MiniMaxChatTarget):
+ """Test that no token metadata is added when response has no usage."""
+ mock_completion = create_mock_completion(content="Response")
+ mock_completion.usage = None
+
+ target._async_client.chat.completions.create = AsyncMock(return_value=mock_completion)
+
+ message = Message(
+ message_pieces=[MessagePiece(role="user", conversation_id="test", original_value="Hello")]
+ )
+
+ result = await target.send_prompt_async(message=message)
+ piece = result[0].message_pieces[0]
+
+ assert "token_usage_model_name" not in piece.prompt_metadata
+
+
+# ============================================================================
+# Identifier Tests
+# ============================================================================
+
+
+def test_get_identifier_includes_class_name(target: MiniMaxChatTarget):
+ """Test that identifier includes correct class name."""
+ identifier = target.get_identifier()
+ assert identifier.class_name == "MiniMaxChatTarget"
+
+
+def test_get_identifier_includes_endpoint(target: MiniMaxChatTarget):
+ """Test that identifier includes endpoint."""
+ identifier = target.get_identifier()
+ assert identifier.params["endpoint"] == "https://api.minimax.io/v1"
+
+
+def test_get_identifier_includes_model(target: MiniMaxChatTarget):
+ """Test that identifier includes model name."""
+ identifier = target.get_identifier()
+ assert identifier.params["model_name"] == "MiniMax-M2.7"
+
+
+def test_get_identifier_includes_temperature(patch_central_database):
+ """Test that identifier includes temperature when set."""
+ target = MiniMaxChatTarget(api_key="test-key", temperature=0.7)
+ identifier = target.get_identifier()
+ assert identifier.params["temperature"] == 0.7
+
+
+# ============================================================================
+# Response Format Tests
+# ============================================================================
+
+
+def test_build_response_format_disabled(target: MiniMaxChatTarget):
+ """Test that response format is None when not enabled."""
+ jrc = _JsonResponseConfig.from_metadata(metadata=None)
+ result = target._build_response_format(jrc)
+ assert result is None
+
+
+def test_build_response_format_json(target: MiniMaxChatTarget):
+ """Test that response format is json_object when enabled."""
+ jrc = _JsonResponseConfig.from_metadata(metadata={"response_format": "json"})
+ result = target._build_response_format(jrc)
+ assert result == {"type": "json_object"}
+
+
+def test_build_response_format_json_schema_falls_back(target: MiniMaxChatTarget):
+ """Test that json_schema falls back to json_object for MiniMax."""
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}}
+ jrc = _JsonResponseConfig.from_metadata(
+ metadata={"response_format": "json", "json_schema": schema}
+ )
+ # MiniMax doesn't support json_schema, always returns json_object
+ result = target._build_response_format(jrc)
+ assert result == {"type": "json_object"}