diff --git a/README.md b/README.md index 20b91c6..c4a73d1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
MiroFlow is the open-source agent framework that maximizes any model's agent performance — and proves it across 9+ benchmarks with reproducible results.
-Plug in GPT-5, Claude, MiroThinker, Kimi, DeepSeek, or any OpenAI-compatible model. Same tools. Same environment. Better results. +Plug in GPT-5, Claude, MiroThinker, Kimi, DeepSeek, MiniMax, or any OpenAI-compatible model. Same tools. Same environment. Better results.

@@ -52,7 +52,7 @@ Plug in GPT-5, Claude, MiroT ## Why MiroFlow ### Make Any Model Better -- **Model-Agnostic Performance**: Plug in any LLM — GPT-5, Claude, MiroThinker, Kimi K2.5, DeepSeek — and get better agent performance through smart rollback, iterative reasoning, and optimized tool orchestration. +- **Model-Agnostic Performance**: Plug in any LLM — GPT-5, Claude, MiroThinker, Kimi K2.5, DeepSeek, MiniMax — and get better agent performance through smart rollback, iterative reasoning, and optimized tool orchestration. - **Comprehensive Benchmarking**: Supports 9+ benchmarks including FutureX, GAIA, HLE, xBench-DeepSearch, BrowseComp, and more. - **One-Line Model Switching**: Change `provider_class` and `model_name` in YAML. Same tools, same prompts, same environment. @@ -115,6 +115,11 @@ llm: llm: provider_class: MiroThinkerSGLangClient model_name: mirothinker-v1.5 + +# MiniMax M2.7 +llm: + provider_class: MiniMaxClient + model_name: MiniMax-M2.7 ``` See [full documentation](https://miromindai.github.io/miroflow/quickstart/) for web app setup, more examples, and configuration options. diff --git a/config/llm/base_minimax.yaml b/config/llm/base_minimax.yaml new file mode 100644 index 0000000..66c147d --- /dev/null +++ b/config/llm/base_minimax.yaml @@ -0,0 +1,24 @@ +# config/llm/base_minimax.yaml +# MiniMax M2.7 via OpenAI-compatible API +# API docs: https://platform.minimax.io/docs/api-reference/text-openai-api +provider_class: "MiniMaxClient" +model_name: "MiniMax-M2.7" +api_key: ${oc.env:MINIMAX_API_KEY,???} +base_url: ${oc.env:MINIMAX_BASE_URL,https://api.minimax.io/v1} + +temperature: 1.0 +top_p: 0.95 +min_p: 0.0 +top_k: -1 + +max_tokens: 32000 +max_context_length: 204800 +async_client: true + +reasoning_effort: null +repetition_penalty: 1.0 + +disable_cache_control: true +keep_tool_result: -1 + +use_tool_calls: false diff --git a/docs/mkdocs/docs/contribute_llm_clients.md b/docs/mkdocs/docs/contribute_llm_clients.md index 02bdd39..a8ba0f5 100644 --- a/docs/mkdocs/docs/contribute_llm_clients.md +++ b/docs/mkdocs/docs/contribute_llm_clients.md @@ -71,6 +71,7 @@ See existing providers in `miroflow/llm/`: - `OpenRouterClient` (`openrouter.py`) - Generic OpenRouter client - `OpenAIClient` (`openai_client.py`) - Generic OpenAI-compatible client - `MiroThinkerSGLangClient` (`mirothinker_sglang.py`) - MiroThinker via SGLang +- `MiniMaxClient` (`minimax_client.py`) - MiniMax M2.7 via OpenAI-compatible API --- diff --git a/docs/mkdocs/docs/llm_clients_overview.md b/docs/mkdocs/docs/llm_clients_overview.md index 0a3fc82..baeb2d5 100644 --- a/docs/mkdocs/docs/llm_clients_overview.md +++ b/docs/mkdocs/docs/llm_clients_overview.md @@ -13,6 +13,7 @@ MiroFlow is model-agnostic — plug in any LLM and get better agent performance | `ClaudeOpenRouterClient` | OpenRouter | anthropic/claude-3.7-sonnet, and other [supported models](https://openrouter.ai/models) | — | `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL` | | `OpenRouterClient` | OpenRouter | Any model on OpenRouter | — | `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL` | | `OpenAIClient` | OpenAI-Compatible | Any OpenAI-compatible model | GAIA Text-Only (Kimi K2.5) | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | +| `MiniMaxClient` | [MiniMax](https://platform.minimax.io) | MiniMax-M2.7, MiniMax-M2.7-highspeed | — | `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` | ## Basic Configuration @@ -33,6 +34,7 @@ Pre-configured base configurations are available in `config/llm/`: | `base_mirothinker.yaml` | SGLang | MiroThinker model via SGLang | | `base_openai.yaml` | OpenAI | GPT models via OpenAI API | | `base_kimi_k25.yaml` | OpenAI-Compatible | Kimi K2.5 model | +| `base_minimax.yaml` | MiniMax | MiniMax M2.7 via OpenAI-compatible API | ## Quick Setup @@ -69,6 +71,12 @@ main_agent: llm: provider_class: MiroThinkerSGLangClient model_name: mirothinker-v1.5 + +# MiniMax M2.7 +main_agent: + llm: + provider_class: MiniMaxClient + model_name: MiniMax-M2.7 ``` See the [Model Comparison Leaderboard](model_comparison.md) for cross-model benchmark results. diff --git a/miroflow/llm/minimax_client.py b/miroflow/llm/minimax_client.py new file mode 100644 index 0000000..91328c5 --- /dev/null +++ b/miroflow/llm/minimax_client.py @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: 2025 MiromindAI +# +# SPDX-License-Identifier: Apache-2.0 + +""" +MiniMax LLM client - OpenAI-compatible provider for MiniMax M2.7 models. + +Supported models: + - MiniMax-M2.7: Peak performance, ultimate value (default) + - MiniMax-M2.7-highspeed: Same performance, faster and more agile + +API docs: https://platform.minimax.io/docs/api-reference/text-openai-api +""" + +from typing import Any, Dict, List + +from omegaconf import DictConfig +from openai import AsyncOpenAI, OpenAI +from tenacity import retry, stop_after_attempt, wait_fixed + +from miroflow.llm.base import LLMClientBase +from miroflow.logging.task_tracer import get_tracer + +logger = get_tracer() + +# MiniMax models supported +MINIMAX_MODELS = {"MiniMax-M2.7", "MiniMax-M2.7-highspeed"} + +# MiniMax temperature range: (0.0, 1.0] +MINIMAX_TEMP_MIN = 0.01 +MINIMAX_TEMP_MAX = 1.0 + + +def _clamp_temperature(temperature: float) -> float: + """Clamp temperature to MiniMax's valid range (0.0, 1.0].""" + if temperature <= 0.0: + return MINIMAX_TEMP_MIN + if temperature > MINIMAX_TEMP_MAX: + return MINIMAX_TEMP_MAX + return temperature + + +class MiniMaxClient(LLMClientBase): + """ + MiniMax LLM client using OpenAI-compatible API. + + MiniMax provides high-performance language models accessible via + an OpenAI-compatible endpoint at https://api.minimax.io/v1. + + Configuration example (YAML): + provider_class: "MiniMaxClient" + model_name: "MiniMax-M2.7" + api_key: ${oc.env:MINIMAX_API_KEY,???} + base_url: ${oc.env:MINIMAX_BASE_URL,https://api.minimax.io/v1} + """ + + def _create_client(self, config: DictConfig): + """Create configured OpenAI-compatible client for MiniMax.""" + if self.async_client: + return AsyncOpenAI( + api_key=self.cfg.api_key, + base_url=self.cfg.base_url, + timeout=1800, + ) + else: + return OpenAI( + api_key=self.cfg.api_key, + base_url=self.cfg.base_url, + timeout=1800, + ) + + @retry(wait=wait_fixed(10), stop=stop_after_attempt(10)) + async def _create_message( + self, + system_prompt: str, + messages: List[Dict[str, Any]], + tools_definitions, + keep_tool_result: int = -1, + ): + """Send message to MiniMax API via OpenAI-compatible endpoint.""" + logger.debug(f" Calling MiniMax LLM ({'async' if self.async_client else 'sync'})") + + # Inject system prompt + if system_prompt: + if messages and messages[0]["role"] in ["system", "developer"]: + messages[0] = { + "role": "system", + "content": [dict(type="text", text=system_prompt)], + } + else: + messages.insert( + 0, + { + "role": "system", + "content": [dict(type="text", text=system_prompt)], + }, + ) + + messages_copy = self._remove_tool_result_from_messages( + messages, keep_tool_result + ) + + if tools_definitions: + tool_list = await self.convert_tool_definition_to_tool_call( + tools_definitions + ) + else: + tool_list = None + + # Clamp temperature to MiniMax valid range + temperature = _clamp_temperature(self.temperature) + + params = { + "model": self.model_name, + "temperature": temperature, + "max_completion_tokens": self.max_tokens, + "messages": messages_copy, + "tools": tool_list, + "stream": False, + } + + if self.top_p != 1.0: + params["top_p"] = self.top_p + + try: + if self.async_client: + response = await self.client.chat.completions.create(**params) + else: + response = self.client.chat.completions.create(**params) + + logger.debug( + f"MiniMax LLM call status: {getattr(response.choices[0], 'finish_reason', 'N/A')}" + ) + return response + except Exception as e: + logger.exception(f"MiniMax LLM call failed: {str(e)}") + raise + + def process_llm_response(self, llm_response) -> tuple[str, bool, dict]: + """Process MiniMax LLM response (OpenAI-compatible format).""" + if not llm_response or not llm_response.choices: + logger.debug("Error: MiniMax LLM did not return a valid response.") + return "", True, {} + + finish_reason = llm_response.choices[0].finish_reason + + if finish_reason == "stop": + text = llm_response.choices[0].message.content or "" + return text, False, {"role": "assistant", "content": text} + + if finish_reason == "tool_calls": + tool_calls = llm_response.choices[0].message.tool_calls + text = llm_response.choices[0].message.content or "" + + if not text: + descriptions = [] + for tc in tool_calls: + descriptions.append( + f"Using tool {tc.function.name} with arguments: {tc.function.arguments}" + ) + text = "\n".join(descriptions) + + assistant_message = { + "role": "assistant", + "content": text, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in tool_calls + ], + } + return text, False, assistant_message + + if finish_reason == "length": + text = llm_response.choices[0].message.content or "" + if text == "": + text = "LLM response is empty. This is likely due to thinking block used up all tokens." + return text, False, {"role": "assistant", "content": text} + + raise ValueError(f"Unsupported finish reason: {finish_reason}") + + def extract_tool_calls_info(self, llm_response, assistant_response_text): + """Extract tool call information from MiniMax response.""" + from miroflow.utils.parsing_utils import parse_llm_response_for_tool_calls + + if llm_response.choices[0].finish_reason == "tool_calls": + return parse_llm_response_for_tool_calls( + llm_response.choices[0].message.tool_calls + ) + return [], [] + + def update_message_history( + self, message_history, tool_call_info, tool_calls_exceeded: bool = False + ): + """Update message history with tool call results.""" + for cur_call_id, tool_result in tool_call_info: + message_history.append( + { + "role": "tool", + "tool_call_id": cur_call_id, + "content": tool_result["text"], + } + ) + return message_history + + def handle_max_turns_reached_summary_prompt(self, message_history, summary_prompt): + """Handle max turns reached summary prompt.""" + return summary_prompt diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/llm/__init__.py b/tests/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/llm/test_minimax_client.py b/tests/llm/test_minimax_client.py new file mode 100644 index 0000000..3d2536f --- /dev/null +++ b/tests/llm/test_minimax_client.py @@ -0,0 +1,392 @@ +# SPDX-FileCopyrightText: 2025 MiromindAI +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for MiniMax LLM client. +""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from omegaconf import OmegaConf + +from miroflow.llm.minimax_client import ( + MINIMAX_MODELS, + MINIMAX_TEMP_MAX, + MINIMAX_TEMP_MIN, + MiniMaxClient, + _clamp_temperature, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_config(**overrides): + """Create a valid MiniMaxClient DictConfig.""" + defaults = { + "provider_class": "MiniMaxClient", + "model_name": "MiniMax-M2.7", + "api_key": "test-key", + "base_url": "https://api.minimax.io/v1", + "temperature": 1.0, + "top_p": 0.95, + "min_p": 0.0, + "top_k": -1, + "max_tokens": 32000, + "max_context_length": 204800, + "async_client": True, + "reasoning_effort": None, + "repetition_penalty": 1.0, + "disable_cache_control": True, + "keep_tool_result": -1, + "use_tool_calls": False, + } + defaults.update(overrides) + return OmegaConf.create(defaults) + + +@pytest.fixture +def minimax_cfg(): + return _make_config() + + +@pytest.fixture +def minimax_sync_cfg(): + return _make_config(async_client=False) + + +# --------------------------------------------------------------------------- +# Temperature clamping +# --------------------------------------------------------------------------- + +class TestClampTemperature: + def test_zero_clamped_to_min(self): + assert _clamp_temperature(0.0) == MINIMAX_TEMP_MIN + + def test_negative_clamped_to_min(self): + assert _clamp_temperature(-0.5) == MINIMAX_TEMP_MIN + + def test_above_max_clamped(self): + assert _clamp_temperature(2.0) == MINIMAX_TEMP_MAX + + def test_within_range_unchanged(self): + assert _clamp_temperature(0.5) == 0.5 + + def test_max_boundary(self): + assert _clamp_temperature(1.0) == 1.0 + + def test_just_above_zero(self): + assert _clamp_temperature(0.01) == 0.01 + + +# --------------------------------------------------------------------------- +# Client instantiation +# --------------------------------------------------------------------------- + +class TestMiniMaxClientInit: + def test_creates_async_client(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + assert client.model_name == "MiniMax-M2.7" + assert client.async_client is True + from openai import AsyncOpenAI + assert isinstance(client.client, AsyncOpenAI) + + def test_creates_sync_client(self, minimax_sync_cfg): + client = MiniMaxClient(minimax_sync_cfg) + assert client.async_client is False + from openai import OpenAI + assert isinstance(client.client, OpenAI) + + def test_highspeed_model(self): + cfg = _make_config(model_name="MiniMax-M2.7-highspeed") + client = MiniMaxClient(cfg) + assert client.model_name == "MiniMax-M2.7-highspeed" + + def test_custom_base_url(self): + cfg = _make_config(base_url="https://api.minimaxi.com/v1") + client = MiniMaxClient(cfg) + assert str(client.client.base_url).startswith("https://api.minimaxi.com") + + +# --------------------------------------------------------------------------- +# Model constants +# --------------------------------------------------------------------------- + +class TestModelConstants: + def test_expected_models(self): + assert "MiniMax-M2.7" in MINIMAX_MODELS + assert "MiniMax-M2.7-highspeed" in MINIMAX_MODELS + assert len(MINIMAX_MODELS) == 2 + + +# --------------------------------------------------------------------------- +# process_llm_response +# --------------------------------------------------------------------------- + +def _mock_response(finish_reason="stop", content="Hello!", tool_calls=None): + """Build a mock OpenAI-style response.""" + message = MagicMock() + message.content = content + message.tool_calls = tool_calls + + choice = MagicMock() + choice.finish_reason = finish_reason + choice.message = message + + response = MagicMock() + response.choices = [choice] + return response + + +class TestProcessLlmResponse: + def test_stop_response(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = _mock_response(finish_reason="stop", content="Hello world") + text, is_invalid, msg = client.process_llm_response(resp) + assert text == "Hello world" + assert is_invalid is False + assert msg["role"] == "assistant" + assert msg["content"] == "Hello world" + + def test_empty_response(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + text, is_invalid, msg = client.process_llm_response(None) + assert text == "" + assert is_invalid is True + assert msg == {} + + def test_empty_choices(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = MagicMock() + resp.choices = [] + text, is_invalid, msg = client.process_llm_response(resp) + assert is_invalid is True + + def test_tool_calls_response(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + tc = MagicMock() + tc.id = "call_123" + tc.function.name = "search" + tc.function.arguments = '{"q":"test"}' + resp = _mock_response(finish_reason="tool_calls", content="", tool_calls=[tc]) + text, is_invalid, msg = client.process_llm_response(resp) + assert is_invalid is False + assert "tool_calls" in msg + assert msg["tool_calls"][0]["id"] == "call_123" + assert "search" in text + + def test_tool_calls_with_content(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + tc = MagicMock() + tc.id = "call_456" + tc.function.name = "fetch" + tc.function.arguments = "{}" + resp = _mock_response(finish_reason="tool_calls", content="Thinking...", tool_calls=[tc]) + text, is_invalid, msg = client.process_llm_response(resp) + assert text == "Thinking..." + assert msg["content"] == "Thinking..." + + def test_length_response(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = _mock_response(finish_reason="length", content="partial") + text, is_invalid, msg = client.process_llm_response(resp) + assert text == "partial" + assert is_invalid is False + + def test_length_empty_content(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = _mock_response(finish_reason="length", content="") + text, _, _ = client.process_llm_response(resp) + assert "thinking block" in text.lower() or "empty" in text.lower() + + def test_unsupported_finish_reason(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = _mock_response(finish_reason="content_filter", content="") + with pytest.raises(ValueError, match="Unsupported finish reason"): + client.process_llm_response(resp) + + +# --------------------------------------------------------------------------- +# extract_tool_calls_info +# --------------------------------------------------------------------------- + +class TestExtractToolCalls: + def test_no_tool_calls_on_stop(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + resp = _mock_response(finish_reason="stop", content="Hi") + ids, results = client.extract_tool_calls_info(resp, "Hi") + assert ids == [] + assert results == [] + + +# --------------------------------------------------------------------------- +# update_message_history +# --------------------------------------------------------------------------- + +class TestUpdateMessageHistory: + def test_appends_tool_results(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + history = [] + tool_info = [("call_1", {"text": "result1"}), ("call_2", {"text": "result2"})] + result = client.update_message_history(history, tool_info) + assert len(result) == 2 + assert result[0]["role"] == "tool" + assert result[0]["tool_call_id"] == "call_1" + assert result[1]["content"] == "result2" + + +# --------------------------------------------------------------------------- +# _create_message (async) +# --------------------------------------------------------------------------- + +class TestCreateMessage: + @pytest.mark.asyncio + async def test_injects_system_prompt(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + messages = [{"role": "user", "content": "Hello"}] + await client._create_message("You are helpful", messages, None) + + call_args = client.client.chat.completions.create.call_args + sent_messages = call_args.kwargs["messages"] + assert sent_messages[0]["role"] == "system" + + @pytest.mark.asyncio + async def test_replaces_existing_system(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + messages = [ + {"role": "system", "content": "old prompt"}, + {"role": "user", "content": "Hello"}, + ] + await client._create_message("new prompt", messages, None) + + call_args = client.client.chat.completions.create.call_args + sent_messages = call_args.kwargs["messages"] + assert sent_messages[0]["content"][0]["text"] == "new prompt" + + @pytest.mark.asyncio + async def test_temperature_clamped(self): + cfg = _make_config(temperature=0.0) + client = MiniMaxClient(cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + await client._create_message("sys", [{"role": "user", "content": "Hi"}], None) + + call_args = client.client.chat.completions.create.call_args + assert call_args.kwargs["temperature"] == MINIMAX_TEMP_MIN + + @pytest.mark.asyncio + async def test_top_p_included_when_not_default(self): + cfg = _make_config(top_p=0.8) + client = MiniMaxClient(cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + await client._create_message("sys", [{"role": "user", "content": "Hi"}], None) + + call_args = client.client.chat.completions.create.call_args + assert call_args.kwargs["top_p"] == 0.8 + + @pytest.mark.asyncio + async def test_top_p_excluded_when_default(self): + cfg = _make_config(top_p=1.0) + client = MiniMaxClient(cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + await client._create_message("sys", [{"role": "user", "content": "Hi"}], None) + + call_args = client.client.chat.completions.create.call_args + assert "top_p" not in call_args.kwargs + + @pytest.mark.asyncio + async def test_stream_false(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + mock_resp = _mock_response() + client.client = AsyncMock() + client.client.chat.completions.create = AsyncMock(return_value=mock_resp) + + await client._create_message("sys", [{"role": "user", "content": "Hi"}], None) + + call_args = client.client.chat.completions.create.call_args + assert call_args.kwargs["stream"] is False + + +# --------------------------------------------------------------------------- +# handle_max_turns_reached_summary_prompt +# --------------------------------------------------------------------------- + +class TestMaxTurnsPrompt: + def test_returns_prompt_unchanged(self, minimax_cfg): + client = MiniMaxClient(minimax_cfg) + assert client.handle_max_turns_reached_summary_prompt([], "summarize") == "summarize" + + +# --------------------------------------------------------------------------- +# Integration test (requires MINIMAX_API_KEY) +# --------------------------------------------------------------------------- + +@pytest.mark.skipif( + not os.environ.get("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set - skipping integration test", +) +class TestMiniMaxIntegration: + @pytest.mark.asyncio + async def test_basic_chat(self): + cfg = _make_config( + api_key=os.environ["MINIMAX_API_KEY"], + base_url=os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1"), + max_tokens=64, + ) + client = MiniMaxClient(cfg) + messages = [{"role": "user", "content": "Say exactly: integration test passed"}] + resp = await client._create_message("You are helpful.", messages, None) + text, is_invalid, _ = client.process_llm_response(resp) + assert not is_invalid + assert len(text) > 0 + + @pytest.mark.asyncio + async def test_highspeed_model(self): + cfg = _make_config( + api_key=os.environ["MINIMAX_API_KEY"], + base_url=os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1"), + model_name="MiniMax-M2.7-highspeed", + max_tokens=32, + ) + client = MiniMaxClient(cfg) + messages = [{"role": "user", "content": "Reply with the word: OK"}] + resp = await client._create_message("Be brief.", messages, None) + text, is_invalid, _ = client.process_llm_response(resp) + assert not is_invalid + assert len(text) > 0 + + @pytest.mark.asyncio + async def test_system_prompt_handled(self): + cfg = _make_config( + api_key=os.environ["MINIMAX_API_KEY"], + base_url=os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1"), + max_tokens=256, + ) + client = MiniMaxClient(cfg) + messages = [{"role": "user", "content": "What is 2+2? Answer with just the number."}] + resp = await client._create_message( + "You are a math tutor. Always answer with just the number.", messages, None + ) + text, is_invalid, _ = client.process_llm_response(resp) + assert not is_invalid + assert len(text) > 0