From 0d2bb18983a757fd210622be8097ce9abbe1cc5d Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 22 Mar 2026 18:56:18 +0800 Subject: [PATCH] Add MiniMax as first-class chat model provider Add MiniMaxChatTarget for MiniMax AI's OpenAI-compatible chat completions API, supporting M2.7, M2.7-highspeed, M2.5, and M2.5-highspeed models with temperature clamping [0,1], thinking tag stripping, JSON mode, and token usage tracking. - New prompt target: pyrit/prompt_target/minimax/ (MiniMaxChatTarget) - 40 unit tests + 3 integration tests - Environment config in .env_example - Documentation in prompt targets overview --- .env_example | 13 + doc/code/targets/0_prompt_targets.md | 1 + pyrit/prompt_target/__init__.py | 2 + pyrit/prompt_target/minimax/__init__.py | 6 + .../minimax/minimax_chat_target.py | 297 ++++++++ .../test_minimax_chat_target_integration.py | 142 ++++ tests/unit/target/test_minimax_chat_target.py | 632 ++++++++++++++++++ 7 files changed, 1093 insertions(+) create mode 100644 pyrit/prompt_target/minimax/__init__.py create mode 100644 pyrit/prompt_target/minimax/minimax_chat_target.py create mode 100644 tests/integration/targets/test_minimax_chat_target_integration.py create mode 100644 tests/unit/target/test_minimax_chat_target.py diff --git a/.env_example b/.env_example index 95270d7e05..b70da0741b 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 cfda73d72d..e7f915b545 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 05af2d67d8..855114ae6a 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 0000000000..1eaddd8757 --- /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 0000000000..a1da0ed126 --- /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 0000000000..8e9c023e6d --- /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 0000000000..d1055db7f9 --- /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"}