diff --git a/.gitignore b/.gitignore index 23524902..2287cbd8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ *.json *.log *.csv +# pycache +__pycache__/ +*.pyc diff --git a/common/ais/__init__.py b/common/ais/__init__.py index e69de29b..41b02243 100644 --- a/common/ais/__init__.py +++ b/common/ais/__init__.py @@ -0,0 +1,5 @@ +from common.ais.chatgpt import ChatGPT +from common.ais.claude import Claude +from common.ais.minimax import MiniMax + +__all__ = ["ChatGPT", "Claude", "MiniMax"] diff --git a/common/ais/minimax.py b/common/ais/minimax.py new file mode 100644 index 00000000..f939f94e --- /dev/null +++ b/common/ais/minimax.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +@file: agentfy/common/ais/minimax.py +@desc: MiniMax API client wrapper for chat via OpenAI-compatible interface +@auth: Callmeiks +""" +import traceback +from typing import Dict, Any, Optional +from decimal import Decimal + +from openai import AsyncOpenAI, OpenAIError +from config import settings +from common.exceptions.exceptions import MiniMaxAPIError +from common.utils.logging import setup_logger + +# Set up logger +logger = setup_logger(__name__) + +MINIMAX_BASE_URL = "https://api.minimax.io/v1" + + +class MiniMax: + """MiniMax API client wrapper using the OpenAI-compatible interface.""" + + # Model pricing configuration (price per token in USD) + PRICING = { + "MiniMax-M2.7": { + "input": Decimal("0.80") / 1000000, + "output": Decimal("4.00") / 1000000, + }, + "MiniMax-M2.7-highspeed": { + "input": Decimal("0.20") / 1000000, + "output": Decimal("1.00") / 1000000, + }, + "MiniMax-M2.5": { + "input": Decimal("0.80") / 1000000, + "output": Decimal("4.00") / 1000000, + }, + "MiniMax-M2.5-highspeed": { + "input": Decimal("0.20") / 1000000, + "output": Decimal("1.00") / 1000000, + }, + } + + # Supported model names + SUPPORTED_MODELS = list(PRICING.keys()) + + def __init__(self, minimax_api_key: Optional[str] = None): + """ + Initialize MiniMax client. + + Args: + minimax_api_key: MiniMax API key; reads MINIMAX_API_KEY env var if not provided. + """ + self.minimax_key = minimax_api_key or settings.minimax_api_key + + if not self.minimax_key: + logger.warning("No MiniMax API key provided, MiniMax functionality will be unavailable") + self.minimax_client = None + else: + self.minimax_client = AsyncOpenAI( + api_key=self.minimax_key, + base_url=MINIMAX_BASE_URL, + timeout=60, + ) + + def _clamp_temperature(self, temperature: float) -> float: + """ + Clamp temperature to the valid MiniMax range (0.0, 1.0]. + + MiniMax requires temperature > 0 and <= 1. + """ + if temperature <= 0.0: + temperature = 0.01 + elif temperature > 1.0: + temperature = 1.0 + return temperature + + def _normalize_model(self, model: str) -> str: + """Return the canonical model name, defaulting to MiniMax-M2.7.""" + if model in self.PRICING: + return model + logger.warning(f"Unknown MiniMax model '{model}', falling back to MiniMax-M2.7") + return "MiniMax-M2.7" + + async def calculate_chat_cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> Dict[str, Any]: + """ + Calculate MiniMax API usage cost. + + Args: + model: Model name. + prompt_tokens: Number of input tokens. + completion_tokens: Number of output tokens. + + Returns: + dict with input_cost, output_cost, total_cost. + """ + model_key = self._normalize_model(model) + input_cost = prompt_tokens * self.PRICING[model_key]["input"] + output_cost = completion_tokens * self.PRICING[model_key]["output"] + total_cost = input_cost + output_cost + + return { + "input_cost": float(input_cost), + "output_cost": float(output_cost), + "total_cost": float(total_cost), + } + + async def chat( + self, + system_prompt: str, + user_prompt: str, + model: str = "MiniMax-M2.7", + temperature: float = 0.7, + max_tokens: int = 10000, + timeout: int = 60, + ) -> Dict[str, Any]: + """ + Call MiniMax chat API via the OpenAI-compatible interface (async). + + Args: + system_prompt: System prompt. + user_prompt: User prompt. + model: Model name (default: MiniMax-M2.7, 204K context). + temperature: Sampling temperature; clamped to (0.0, 1.0]. + max_tokens: Maximum output tokens. + timeout: Request timeout in seconds. + + Returns: + dict with model, temperature, max_tokens, response, cost. + + Raises: + MiniMaxAPIError: When the API call fails. + """ + if not self.minimax_client: + raise MiniMaxAPIError( + "MiniMax client not initialized, chat functionality unavailable", + {"details": "Please provide a valid MINIMAX_API_KEY"}, + ) + + model = self._normalize_model(model) + temperature = self._clamp_temperature(temperature) + + try: + chat_completion = await self.minimax_client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + + cost = await self.calculate_chat_cost( + model, + chat_completion.usage.prompt_tokens, + chat_completion.usage.completion_tokens, + ) + + result = { + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "response": chat_completion.model_dump(), + "cost": cost, + } + + logger.info( + f"MiniMax response: model={model}, " + f"completion={chat_completion.usage.completion_tokens}/{chat_completion.usage.total_tokens} " + f"input_cost=${cost['input_cost']:.6f}, output_cost=${cost['output_cost']:.6f}, " + f"total_cost=${cost['total_cost']:.6f}" + ) + + return result + + except OpenAIError as e: + logger.error( + f"MiniMax API error: {str(e)}", + {"model": model, "temperature": temperature, "max_tokens": max_tokens}, + ) + raise MiniMaxAPIError( + "Error calling MiniMax API", + {"details": str(e)}, + ) + except Exception as e: + logger.error(f"Unexpected error calling MiniMax: {str(e)}") + traceback.print_exc() + raise MiniMaxAPIError( + "Unexpected error calling MiniMax", + {"details": str(e)}, + ) diff --git a/common/exceptions/exceptions.py b/common/exceptions/exceptions.py index 8f5b3109..327774e3 100644 --- a/common/exceptions/exceptions.py +++ b/common/exceptions/exceptions.py @@ -72,6 +72,10 @@ class ClaudeAPIError(ReasoningException): """Raised when Claude API call fails.""" pass +class MiniMaxAPIError(ReasoningException): + """Raised when MiniMax API call fails.""" + pass + # Action Module Exceptions class ActionException(SocialMediaAgentException): """Base exception for action module.""" diff --git a/config.py b/config.py index 34ad96f3..30cea973 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): # External Service API Keys openai_api_key: Optional[str] = Field(None, env="OPENAI_API_KEY") anthropic_api_key: Optional[str] = Field(None, env="ANTHROPIC_API_KEY") + minimax_api_key: Optional[str] = Field(None, env="MINIMAX_API_KEY") tikhub_api_key: Optional[str] = Field(None, env="TIKHUB_API_KEY") lemonfox_api_key: Optional[str] = Field(None, env="LEMONFOX_API_KEY") elevenlabs_api_key: Optional[str] = Field(None, env="ELEVENLABS_API_KEY") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..675a11f9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +"""Pytest configuration for Agentfy tests.""" +import sys +import os + +# Allow imports from the project root +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 00000000..7e3a2980 --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +""" +Integration tests for common/ais/minimax.py + +These tests verify the integration with the MiniMax API using the +MINIMAX_API_KEY environment variable. They are skipped automatically +when the key is not present. +""" +import os +import pytest + + +pytestmark = pytest.mark.skipif( + not os.getenv("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set — skipping integration tests", +) + + +@pytest.fixture +def minimax(): + from common.ais.minimax import MiniMax + return MiniMax() + + +class TestMiniMaxIntegration: + @pytest.mark.asyncio + async def test_chat_returns_non_empty_response(self, minimax): + result = await minimax.chat( + system_prompt="You are a helpful assistant.", + user_prompt="Say hello in one word.", + model="MiniMax-M2.7", + max_tokens=20, + ) + choices = result["response"].get("choices", []) + assert len(choices) > 0 + content = choices[0]["message"]["content"] + assert isinstance(content, str) and len(content) > 0 + + @pytest.mark.asyncio + async def test_chat_highspeed_model(self, minimax): + result = await minimax.chat( + system_prompt="You are a helpful assistant.", + user_prompt="Reply with the single word 'OK'.", + model="MiniMax-M2.7-highspeed", + max_tokens=10, + ) + assert result["model"] == "MiniMax-M2.7-highspeed" + + @pytest.mark.asyncio + async def test_chat_cost_is_positive(self, minimax): + result = await minimax.chat( + system_prompt="Answer briefly.", + user_prompt="What is 2+2?", + model="MiniMax-M2.7", + max_tokens=20, + ) + assert result["cost"]["total_cost"] > 0 diff --git a/tests/test_minimax_unit.py b/tests/test_minimax_unit.py new file mode 100644 index 00000000..abfd3532 --- /dev/null +++ b/tests/test_minimax_unit.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for common/ais/minimax.py +""" +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +def _make_minimax(api_key="test-key"): + """Return a MiniMax instance with a dummy key (no real HTTP).""" + with patch("common.ais.minimax.settings") as mock_settings: + mock_settings.minimax_api_key = api_key + from common.ais.minimax import MiniMax + return MiniMax(minimax_api_key=api_key) + + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +class TestMiniMaxInit: + def test_init_with_explicit_key(self): + from common.ais.minimax import MiniMax + with patch("common.ais.minimax.settings") as ms: + ms.minimax_api_key = None + mm = MiniMax(minimax_api_key="explicit-key") + assert mm.minimax_key == "explicit-key" + assert mm.minimax_client is not None + + def test_init_no_key_client_is_none(self): + from common.ais.minimax import MiniMax + with patch("common.ais.minimax.settings") as ms: + ms.minimax_api_key = None + mm = MiniMax(minimax_api_key=None) + assert mm.minimax_client is None + + def test_init_reads_from_settings(self): + from common.ais.minimax import MiniMax + with patch("common.ais.minimax.settings") as ms: + ms.minimax_api_key = "settings-key" + mm = MiniMax() + assert mm.minimax_key == "settings-key" + + +# --------------------------------------------------------------------------- +# Temperature clamping +# --------------------------------------------------------------------------- + +class TestClampTemperature: + def setup_method(self): + self.mm = _make_minimax() + + def test_valid_temperature_unchanged(self): + assert self.mm._clamp_temperature(0.7) == pytest.approx(0.7) + + def test_temperature_zero_clamped_up(self): + assert self.mm._clamp_temperature(0.0) == pytest.approx(0.01) + + def test_negative_temperature_clamped_up(self): + assert self.mm._clamp_temperature(-1.0) == pytest.approx(0.01) + + def test_temperature_above_one_clamped_down(self): + assert self.mm._clamp_temperature(1.5) == pytest.approx(1.0) + + def test_temperature_exactly_one_unchanged(self): + assert self.mm._clamp_temperature(1.0) == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# Model normalization +# --------------------------------------------------------------------------- + +class TestNormalizeModel: + def setup_method(self): + self.mm = _make_minimax() + + def test_known_model_returned_unchanged(self): + assert self.mm._normalize_model("MiniMax-M2.7") == "MiniMax-M2.7" + assert self.mm._normalize_model("MiniMax-M2.7-highspeed") == "MiniMax-M2.7-highspeed" + assert self.mm._normalize_model("MiniMax-M2.5") == "MiniMax-M2.5" + assert self.mm._normalize_model("MiniMax-M2.5-highspeed") == "MiniMax-M2.5-highspeed" + + def test_unknown_model_falls_back_to_m27(self): + assert self.mm._normalize_model("unknown-model") == "MiniMax-M2.7" + + +# --------------------------------------------------------------------------- +# Cost calculation +# --------------------------------------------------------------------------- + +class TestCalculateChatCost: + def setup_method(self): + self.mm = _make_minimax() + + @pytest.mark.asyncio + async def test_cost_m27(self): + cost = await self.mm.calculate_chat_cost("MiniMax-M2.7", 1000, 500) + assert cost["input_cost"] == pytest.approx(1000 * 0.80 / 1_000_000) + assert cost["output_cost"] == pytest.approx(500 * 4.00 / 1_000_000) + assert cost["total_cost"] == pytest.approx(cost["input_cost"] + cost["output_cost"]) + + @pytest.mark.asyncio + async def test_cost_m27_highspeed(self): + cost = await self.mm.calculate_chat_cost("MiniMax-M2.7-highspeed", 2000, 1000) + assert cost["input_cost"] == pytest.approx(2000 * 0.20 / 1_000_000) + assert cost["output_cost"] == pytest.approx(1000 * 1.00 / 1_000_000) + + @pytest.mark.asyncio + async def test_cost_unknown_model_uses_fallback(self): + # unknown model falls back to M2.7 pricing + cost = await self.mm.calculate_chat_cost("bad-model", 1000, 1000) + assert cost["input_cost"] == pytest.approx(1000 * 0.80 / 1_000_000) + + +# --------------------------------------------------------------------------- +# chat() +# --------------------------------------------------------------------------- + +def _make_completion_mock(prompt_tokens=100, completion_tokens=50, model="MiniMax-M2.7"): + """Build a mock chat completion object.""" + choice = MagicMock() + choice.message.content = "Hello from MiniMax" + choice.finish_reason = "stop" + choice.index = 0 + + usage = MagicMock() + usage.prompt_tokens = prompt_tokens + usage.completion_tokens = completion_tokens + usage.total_tokens = prompt_tokens + completion_tokens + + completion = MagicMock() + completion.usage = usage + completion.model = model + completion.model_dump.return_value = { + "id": "chat-123", + "choices": [{"message": {"role": "assistant", "content": "Hello from MiniMax"}}], + "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}, + } + return completion + + +class TestChat: + def setup_method(self): + self.mm = _make_minimax() + + @pytest.mark.asyncio + async def test_chat_raises_when_no_client(self): + from common.ais.minimax import MiniMax + from common.exceptions.exceptions import MiniMaxAPIError + with patch("common.ais.minimax.settings") as ms: + ms.minimax_api_key = None + mm = MiniMax(minimax_api_key=None) + with pytest.raises(MiniMaxAPIError, match="not initialized"): + await mm.chat("sys", "user") + + @pytest.mark.asyncio + async def test_chat_success_returns_expected_keys(self): + mock_completion = _make_completion_mock() + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat = MagicMock() + self.mm.minimax_client.chat.completions = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("Be helpful", "What is AI?") + assert "model" in result + assert "response" in result + assert "cost" in result + assert "temperature" in result + + @pytest.mark.asyncio + async def test_chat_temperature_is_clamped(self): + mock_completion = _make_completion_mock() + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("sys", "user", temperature=0.0) + # clamped up from 0.0 to 0.01 + assert result["temperature"] == pytest.approx(0.01) + + @pytest.mark.asyncio + async def test_chat_uses_default_model(self): + mock_completion = _make_completion_mock() + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("sys", "user") + assert result["model"] == "MiniMax-M2.7" + + @pytest.mark.asyncio + async def test_chat_wraps_openai_error(self): + from openai import OpenAIError + from common.exceptions.exceptions import MiniMaxAPIError + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock( + side_effect=OpenAIError("rate limit") + ) + with pytest.raises(MiniMaxAPIError): + await self.mm.chat("sys", "user") + + @pytest.mark.asyncio + async def test_chat_wraps_generic_exception(self): + from common.exceptions.exceptions import MiniMaxAPIError + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock( + side_effect=RuntimeError("network failure") + ) + with pytest.raises(MiniMaxAPIError): + await self.mm.chat("sys", "user") + + @pytest.mark.asyncio + async def test_chat_cost_values_are_floats(self): + mock_completion = _make_completion_mock(prompt_tokens=200, completion_tokens=100) + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("sys", "user") + cost = result["cost"] + assert isinstance(cost["input_cost"], float) + assert isinstance(cost["output_cost"], float) + assert isinstance(cost["total_cost"], float) + + @pytest.mark.asyncio + async def test_chat_highspeed_model(self): + mock_completion = _make_completion_mock(model="MiniMax-M2.7-highspeed") + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("sys", "user", model="MiniMax-M2.7-highspeed") + assert result["model"] == "MiniMax-M2.7-highspeed" + + @pytest.mark.asyncio + async def test_chat_m25_model(self): + mock_completion = _make_completion_mock(model="MiniMax-M2.5") + self.mm.minimax_client = MagicMock() + self.mm.minimax_client.chat.completions.create = AsyncMock(return_value=mock_completion) + + result = await self.mm.chat("sys", "user", model="MiniMax-M2.5") + assert result["model"] == "MiniMax-M2.5"