diff --git a/gui_agents/s2/core/engine.py b/gui_agents/s2/core/engine.py index cef83fd8..f58e7e7b 100644 --- a/gui_agents/s2/core/engine.py +++ b/gui_agents/s2/core/engine.py @@ -321,7 +321,7 @@ def __init__( model=None, api_version=None, rate_limit=-1, - **kwargs + **kwargs, ): assert model is not None, "model must be provided" self.model = model @@ -390,7 +390,7 @@ def generate( top_p=0.8, repetition_penalty=1.05, max_new_tokens=512, - **kwargs + **kwargs, ): api_key = self.api_key or os.getenv("vLLM_API_KEY") if api_key is None: @@ -483,3 +483,73 @@ def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): .choices[0] .message.content ) + + +class LMMEngineMiniMax(LMMEngine): + _UNSUPPORTED_PARAMS = frozenset( + [ + "top_k", + "stop_sequences", + "service_tier", + "mcp_servers", + "context_management", + "container", + ] + ) + + def __init__( + self, + base_url=None, + api_key=None, + model=None, + temperature=None, + **kwargs, + ): + assert model is not None, "model must be provided" + self.model = model + self.base_url = base_url + self.api_key = api_key + self.llm_client = None + self.temperature = temperature + + @backoff.on_exception( + backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60 + ) + def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): + api_key = self.api_key or os.getenv("MINIMAX_API_KEY") + if api_key is None: + raise ValueError( + "An API Key needs to be provided in either the api_key parameter or as an environment variable named MINIMAX_API_KEY" + ) + base_url = ( + self.base_url + or os.getenv("MINIMAX_BASE_URL") + or "https://api.minimax.io/anthropic" + ) + if not self.llm_client: + from anthropic import Anthropic as _Anthropic + + self.llm_client = _Anthropic(api_key=api_key, base_url=base_url) + # Use instance temperature if set, otherwise use generate argument + temp = self.temperature if self.temperature is not None else temperature + # MiniMax temperature must be in (0.0, 1.0]; clamp to 1.0 if not positive + if temp <= 0.0: + temp = 1.0 + # Filter out parameters not supported by MiniMax + filtered_kwargs = { + k: v for k, v in kwargs.items() if k not in self._UNSUPPORTED_PARAMS + } + system_text = messages[0]["content"][0]["text"] + conversation = messages[1:] + return ( + self.llm_client.messages.create( + model=self.model, + system=system_text, + messages=conversation, + max_tokens=max_new_tokens if max_new_tokens else 4096, + temperature=temp, + **filtered_kwargs, + ) + .content[0] + .text + ) diff --git a/gui_agents/s2/core/mllm.py b/gui_agents/s2/core/mllm.py index 6f2a516d..65ba9c1f 100644 --- a/gui_agents/s2/core/mllm.py +++ b/gui_agents/s2/core/mllm.py @@ -11,6 +11,7 @@ LMMEngineParasail, LMMEnginevLLM, LMMEngineGemini, + LMMEngineMiniMax, ) @@ -35,6 +36,8 @@ def __init__(self, engine_params=None, system_prompt=None, engine=None): self.engine = LMMEngineOpenRouter(**engine_params) elif engine_type == "parasail": self.engine = LMMEngineParasail(**engine_params) + elif engine_type == "minimax": + self.engine = LMMEngineMiniMax(**engine_params) else: raise ValueError("engine_type is not supported") else: @@ -180,8 +183,8 @@ def add_message( self.messages.append(message) - # For API-style inference from Anthropic - elif isinstance(self.engine, LMMEngineAnthropic): + # For API-style inference from Anthropic or MiniMax (Anthropic-compatible) + elif isinstance(self.engine, (LMMEngineAnthropic, LMMEngineMiniMax)): # infer role from previous message if role != "user": if self.messages[-1]["role"] == "system": diff --git a/gui_agents/s2_5/core/engine.py b/gui_agents/s2_5/core/engine.py index 3b17de60..857e4b39 100644 --- a/gui_agents/s2_5/core/engine.py +++ b/gui_agents/s2_5/core/engine.py @@ -439,3 +439,73 @@ def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): .choices[0] .message.content ) + + +class LMMEngineMiniMax(LMMEngine): + _UNSUPPORTED_PARAMS = frozenset( + [ + "top_k", + "stop_sequences", + "service_tier", + "mcp_servers", + "context_management", + "container", + ] + ) + + def __init__( + self, + base_url=None, + api_key=None, + model=None, + temperature=None, + **kwargs, + ): + assert model is not None, "model must be provided" + self.model = model + self.base_url = base_url + self.api_key = api_key + self.llm_client = None + self.temperature = temperature + + @backoff.on_exception( + backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60 + ) + def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): + api_key = self.api_key or os.getenv("MINIMAX_API_KEY") + if api_key is None: + raise ValueError( + "An API Key needs to be provided in either the api_key parameter or as an environment variable named MINIMAX_API_KEY" + ) + base_url = ( + self.base_url + or os.getenv("MINIMAX_BASE_URL") + or "https://api.minimax.io/anthropic" + ) + if not self.llm_client: + from anthropic import Anthropic as _Anthropic + + self.llm_client = _Anthropic(api_key=api_key, base_url=base_url) + # Use instance temperature if set, otherwise use generate argument + temp = self.temperature if self.temperature is not None else temperature + # MiniMax temperature must be in (0.0, 1.0]; clamp to 1.0 if not positive + if temp <= 0.0: + temp = 1.0 + # Filter out parameters not supported by MiniMax + filtered_kwargs = { + k: v for k, v in kwargs.items() if k not in self._UNSUPPORTED_PARAMS + } + system_text = messages[0]["content"][0]["text"] + conversation = messages[1:] + return ( + self.llm_client.messages.create( + model=self.model, + system=system_text, + messages=conversation, + max_tokens=max_new_tokens if max_new_tokens else 4096, + temperature=temp, + **filtered_kwargs, + ) + .content[0] + .text + ) diff --git a/gui_agents/s2_5/core/mllm.py b/gui_agents/s2_5/core/mllm.py index cd7cd038..928722aa 100644 --- a/gui_agents/s2_5/core/mllm.py +++ b/gui_agents/s2_5/core/mllm.py @@ -11,6 +11,7 @@ LMMEngineParasail, LMMEnginevLLM, LMMEngineGemini, + LMMEngineMiniMax, ) @@ -35,6 +36,8 @@ def __init__(self, engine_params=None, system_prompt=None, engine=None): self.engine = LMMEngineOpenRouter(**engine_params) elif engine_type == "parasail": self.engine = LMMEngineParasail(**engine_params) + elif engine_type == "minimax": + self.engine = LMMEngineMiniMax(**engine_params) else: raise ValueError("engine_type is not supported") else: @@ -180,8 +183,8 @@ def add_message( self.messages.append(message) - # For API-style inference from Anthropic - elif isinstance(self.engine, LMMEngineAnthropic): + # For API-style inference from Anthropic or MiniMax (Anthropic-compatible) + elif isinstance(self.engine, (LMMEngineAnthropic, LMMEngineMiniMax)): # infer role from previous message if role != "user": if self.messages[-1]["role"] == "system": diff --git a/gui_agents/s3/core/engine.py b/gui_agents/s3/core/engine.py index 7bf90f14..c1bdfeb0 100644 --- a/gui_agents/s3/core/engine.py +++ b/gui_agents/s3/core/engine.py @@ -443,3 +443,73 @@ def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): .choices[0] .message.content ) + + +class LMMEngineMiniMax(LMMEngine): + _UNSUPPORTED_PARAMS = frozenset( + [ + "top_k", + "stop_sequences", + "service_tier", + "mcp_servers", + "context_management", + "container", + ] + ) + + def __init__( + self, + base_url=None, + api_key=None, + model=None, + temperature=None, + **kwargs, + ): + assert model is not None, "model must be provided" + self.model = model + self.base_url = base_url + self.api_key = api_key + self.llm_client = None + self.temperature = temperature + + @backoff.on_exception( + backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60 + ) + def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs): + api_key = self.api_key or os.getenv("MINIMAX_API_KEY") + if api_key is None: + raise ValueError( + "An API Key needs to be provided in either the api_key parameter or as an environment variable named MINIMAX_API_KEY" + ) + base_url = ( + self.base_url + or os.getenv("MINIMAX_BASE_URL") + or "https://api.minimax.io/anthropic" + ) + if not self.llm_client: + from anthropic import Anthropic as _Anthropic + + self.llm_client = _Anthropic(api_key=api_key, base_url=base_url) + # Use instance temperature if set, otherwise use generate argument + temp = self.temperature if self.temperature is not None else temperature + # MiniMax temperature must be in (0.0, 1.0]; clamp to 1.0 if not positive + if temp <= 0.0: + temp = 1.0 + # Filter out parameters not supported by MiniMax + filtered_kwargs = { + k: v for k, v in kwargs.items() if k not in self._UNSUPPORTED_PARAMS + } + system_text = messages[0]["content"][0]["text"] + conversation = messages[1:] + return ( + self.llm_client.messages.create( + model=self.model, + system=system_text, + messages=conversation, + max_tokens=max_new_tokens if max_new_tokens else 4096, + temperature=temp, + **filtered_kwargs, + ) + .content[0] + .text + ) diff --git a/gui_agents/s3/core/mllm.py b/gui_agents/s3/core/mllm.py index fb49e4b0..a5121ade 100644 --- a/gui_agents/s3/core/mllm.py +++ b/gui_agents/s3/core/mllm.py @@ -11,6 +11,7 @@ LMMEngineParasail, LMMEnginevLLM, LMMEngineGemini, + LMMEngineMiniMax, ) @@ -35,6 +36,8 @@ def __init__(self, engine_params=None, system_prompt=None, engine=None): self.engine = LMMEngineOpenRouter(**engine_params) elif engine_type == "parasail": self.engine = LMMEngineParasail(**engine_params) + elif engine_type == "minimax": + self.engine = LMMEngineMiniMax(**engine_params) else: raise ValueError(f"engine_type '{engine_type}' is not supported") else: @@ -180,8 +183,8 @@ def add_message( self.messages.append(message) - # For API-style inference from Anthropic - elif isinstance(self.engine, LMMEngineAnthropic): + # For API-style inference from Anthropic or MiniMax (Anthropic-compatible) + elif isinstance(self.engine, (LMMEngineAnthropic, LMMEngineMiniMax)): # infer role from previous message if role != "user": if self.messages[-1]["role"] == "system": diff --git a/models.md b/models.md index d7ebe058..7b02feb1 100644 --- a/models.md +++ b/models.md @@ -1,4 +1,4 @@ -We support the following APIs for MLLM inference: OpenAI, Anthropic, Gemini, Azure OpenAI, vLLM for local models, and Open Router. To use these APIs, you need to set the corresponding environment variables: +We support the following APIs for MLLM inference: OpenAI, Anthropic, Gemini, Azure OpenAI, vLLM for local models, Open Router, and MiniMax. To use these APIs, you need to set the corresponding environment variables: 1. OpenAI @@ -55,6 +55,14 @@ agent = AgentS2_5( ) ``` +7. MiniMax + +``` +export MINIMAX_API_KEY= +``` + +Supported models: `MiniMax-M3` (default, 512K context, 128K max output, image input support), `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` + To use the underlying Multimodal Agent (LMMAgent) which wraps LLMs with message handling functionality, you can use the following code snippet: ```python diff --git a/tests/test_minimax_engine.py b/tests/test_minimax_engine.py new file mode 100644 index 00000000..7c518a49 --- /dev/null +++ b/tests/test_minimax_engine.py @@ -0,0 +1,243 @@ +"""Unit tests for LMMEngineMiniMax.""" + +import os +import unittest +from unittest.mock import MagicMock, patch + +from gui_agents.s2_5.core.engine import LMMEngineMiniMax +from gui_agents.s2_5.core.mllm import LMMAgent + + +class TestLMMEngineMiniMaxInit(unittest.TestCase): + def test_creates_instance_with_model(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + self.assertIsNotNone(engine) + self.assertEqual(engine.model, "MiniMax-M2.7") + + def test_raises_without_model(self): + with self.assertRaises(AssertionError): + LMMEngineMiniMax(api_key="test-key") + + def test_stores_api_key(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="my-key") + self.assertEqual(engine.api_key, "my-key") + + def test_stores_custom_base_url(self): + engine = LMMEngineMiniMax( + model="MiniMax-M2.7", + api_key="test-key", + base_url="https://custom.api.io/anthropic", + ) + self.assertEqual(engine.base_url, "https://custom.api.io/anthropic") + + def test_default_base_url_is_none(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + self.assertIsNone(engine.base_url) + + def test_stores_temperature(self): + engine = LMMEngineMiniMax( + model="MiniMax-M2.7", api_key="test-key", temperature=0.7 + ) + self.assertEqual(engine.temperature, 0.7) + + +class TestLMMEngineMiniMaxGenerate(unittest.TestCase): + def _make_messages(self, system="You are a helpful assistant.", user="Hello"): + return [ + {"role": "system", "content": [{"type": "text", "text": system}]}, + {"role": "user", "content": [{"type": "text", "text": user}]}, + ] + + def _make_mock_response(self, text="test response"): + mock_content = MagicMock() + mock_content.text = text + mock_response = MagicMock() + mock_response.content = [mock_content] + return mock_response + + def test_raises_without_api_key(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7") + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("MINIMAX_API_KEY", None) + with self.assertRaises(ValueError, msg="MINIMAX_API_KEY"): + engine.generate(self._make_messages()) + + def test_uses_env_api_key(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + + with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key"}): + with patch("anthropic.Anthropic", return_value=mock_client): + engine.generate(self._make_messages()) + + mock_client.messages.create.assert_called_once() + + def test_default_base_url_is_minimax_anthropic(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + + captured_base_url = {} + + def fake_anthropic(api_key, base_url): + captured_base_url["url"] = base_url + return mock_client + + with patch( + "gui_agents.s2_5.core.engine.LMMEngineMiniMax.generate.__wrapped__", + create=True, + ): + pass + + # Directly test base_url resolution + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("MINIMAX_BASE_URL", None) + resolved = ( + engine.base_url + or os.environ.get("MINIMAX_BASE_URL") + or "https://api.minimax.io/anthropic" + ) + self.assertEqual(resolved, "https://api.minimax.io/anthropic") + + def test_temperature_clamped_from_zero(self): + """Temperature=0.0 should be clamped to 1.0 for MiniMax.""" + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate(self._make_messages(), temperature=0.0) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["temperature"], 1.0) + + def test_positive_temperature_preserved(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate(self._make_messages(), temperature=0.7) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["temperature"], 0.7) + + def test_instance_temperature_overrides_call_temperature(self): + engine = LMMEngineMiniMax( + model="MiniMax-M2.7", api_key="test-key", temperature=0.5 + ) + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate(self._make_messages(), temperature=0.0) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["temperature"], 0.5) + + def test_unsupported_params_filtered(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate( + self._make_messages(), + temperature=0.7, + top_k=40, + stop_sequences=["END"], + service_tier="auto", + ) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertNotIn("top_k", call_kwargs) + self.assertNotIn("stop_sequences", call_kwargs) + self.assertNotIn("service_tier", call_kwargs) + + def test_system_message_extracted_correctly(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + messages = self._make_messages(system="Custom system prompt") + engine.generate(messages, temperature=0.5) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["system"], "Custom system prompt") + + def test_conversation_excludes_system_message(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + messages = self._make_messages() + engine.generate(messages, temperature=0.5) + + call_kwargs = mock_client.messages.create.call_args[1] + # Conversation should not include the system message + for msg in call_kwargs["messages"]: + self.assertNotEqual(msg.get("role"), "system") + + def test_model_name_passed(self): + engine = LMMEngineMiniMax(model="MiniMax-M3", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate(self._make_messages(), temperature=0.5) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["model"], "MiniMax-M3") + + def test_highspeed_model_name_passed(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7-highspeed", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response() + engine.llm_client = mock_client + + engine.generate(self._make_messages(), temperature=0.5) + + call_kwargs = mock_client.messages.create.call_args[1] + self.assertEqual(call_kwargs["model"], "MiniMax-M2.7-highspeed") + + def test_returns_text_content(self): + engine = LMMEngineMiniMax(model="MiniMax-M2.7", api_key="test-key") + mock_client = MagicMock() + mock_client.messages.create.return_value = self._make_mock_response( + "hello world" + ) + engine.llm_client = mock_client + + result = engine.generate(self._make_messages(), temperature=0.5) + self.assertEqual(result, "hello world") + + +class TestLMMAgentMiniMax(unittest.TestCase): + def test_minimax_engine_type_creates_minimax_engine(self): + engine_params = { + "engine_type": "minimax", + "model": "MiniMax-M2.7", + "api_key": "test-key", + } + agent = LMMAgent(engine_params=engine_params) + self.assertIsInstance(agent.engine, LMMEngineMiniMax) + + def test_minimax_in_add_message_uses_anthropic_format(self): + engine_params = { + "engine_type": "minimax", + "model": "MiniMax-M2.7", + "api_key": "test-key", + } + agent = LMMAgent(engine_params=engine_params, system_prompt="Test") + agent.add_message("Hello") + # Messages should contain system + user message + self.assertEqual(len(agent.messages), 2) + self.assertEqual(agent.messages[-1]["role"], "user") + self.assertEqual(agent.messages[-1]["content"][0]["type"], "text") + + +if __name__ == "__main__": + unittest.main()