diff --git a/README.md b/README.md index bd282e9..5b4f431 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,14 @@ Reasoning scatter (tokens/cost toggle in the viewer) vs. green rate. ```bash export OPENROUTER_API_KEY=your_key_here export OPENAI_API_KEY=your_openai_key_here # required only for models routed to OpenAI +export MINIMAX_API_KEY=your_minimax_key_here # required only for models routed to MiniMax export OPENAI_PROJECT=proj_xxx # optional: force OpenAI requests to a specific project export OPENAI_ORGANIZATION=org_xxx # optional: force organization context ``` Provider routing is configured per model via `collect.model_providers` and `grade.model_providers` in config (default is OpenRouter), for example: -`{"*":"openrouter","gpt-5.3":"openai"}`. +`{"*":"openrouter","gpt-5.3":"openai","minimax/*":"minimax"}`. 2. Run collection + primary judge (Claude by default): diff --git a/config.json b/config.json index 78ec435..d129cf0 100644 --- a/config.json +++ b/config.json @@ -50,13 +50,15 @@ "google/gemma-3-27b-it", "qwen/qwen3.5-397b-a17b", "moonshotai/kimi-k2.5", - "minimax/minimax-m2.5" + "minimax/minimax-m2.7", + "minimax/minimax-m2.7-highspeed" ], "models_file": "", "model_providers": { "*": "openrouter", "openai/gpt-5.4-mini": "openai", - "openai/gpt-5.4-nano": "openai" + "openai/gpt-5.4-nano": "openai", + "minimax/*": "minimax" }, "num_runs": 1, "parallelism": 12, @@ -93,7 +95,8 @@ "z-ai/glm-5": ["none", "high"], "qwen/qwen3.5-397b-a17b": ["none", "high"], "moonshotai/kimi-k2.5": ["none", "high"], - "minimax/minimax-m2.5": ["low", "high"] + "minimax/minimax-m2.7": ["none", "high"], + "minimax/minimax-m2.7-highspeed": ["none", "high"] }, "shuffle_tasks": true }, diff --git a/config.v2.json b/config.v2.json index c6ed8b7..ba176f9 100644 --- a/config.v2.json +++ b/config.v2.json @@ -50,13 +50,15 @@ "google/gemma-3-27b-it", "qwen/qwen3.5-397b-a17b", "moonshotai/kimi-k2.5", - "minimax/minimax-m2.5" + "minimax/minimax-m2.7", + "minimax/minimax-m2.7-highspeed" ], "models_file": "", "model_providers": { "*": "openrouter", "openai/gpt-5.4-mini": "openai", - "openai/gpt-5.4-nano": "openai" + "openai/gpt-5.4-nano": "openai", + "minimax/*": "minimax" }, "num_runs": 1, "parallelism": 64, @@ -175,8 +177,12 @@ "none", "high" ], - "minimax/minimax-m2.5": [ - "low", + "minimax/minimax-m2.7": [ + "none", + "high" + ], + "minimax/minimax-m2.7-highspeed": [ + "none", "high" ] }, diff --git a/scripts/openrouter_benchmark.py b/scripts/openrouter_benchmark.py index a2bdee9..e13c6c4 100644 --- a/scripts/openrouter_benchmark.py +++ b/scripts/openrouter_benchmark.py @@ -65,11 +65,13 @@ "openrouter": "openrouter", "or": "openrouter", "openai": "openai", + "minimax": "minimax", } MODEL_PROVIDER_VALUES: tuple[str, ...] = ( "openrouter", "openai", + "minimax", ) DEFAULT_MODEL_PROVIDER = "openrouter" @@ -2195,6 +2197,10 @@ class OpenAIAPIError(ProviderAPIError): """Errors from OpenAI Responses API calls.""" +class MiniMaxAPIError(ProviderAPIError): + """Errors from MiniMax chat/completions calls.""" + + class OpenRouterClient: def __init__(self, api_key: str, timeout_seconds: int) -> None: if timeout_seconds < 1: @@ -2443,6 +2449,134 @@ def chat( raise last_error +def _minimax_model_id(model: str) -> str: + """Strip the ``minimax/`` namespace prefix if present.""" + cleaned = str(model).strip() + if cleaned.startswith("minimax/"): + _, remainder = cleaned.split("/", 1) + if remainder: + return remainder + return cleaned + + +def _minimax_clamp_temperature(temperature: float | None) -> float | None: + """MiniMax requires temperature in the open interval (0.0, 1.0].""" + if temperature is None: + return None + return max(0.01, min(float(temperature), 1.0)) + + +def _strip_think_tags(text: str) -> str: + """Remove ```` blocks that MiniMax M2.5+ may emit.""" + return re.sub(r"[\s\S]*?", "", text).strip() + + +class MiniMaxClient: + """Client for the MiniMax OpenAI-compatible chat/completions API.""" + + def __init__(self, api_key: str, timeout_seconds: int) -> None: + if timeout_seconds < 1: + raise ValueError("timeout_seconds must be >= 1") + self.api_key = api_key + self.timeout_seconds = timeout_seconds + self.base_url = "https://api.minimax.io/v1/chat/completions" + + def chat( + self, + *, + model: str, + messages: list[dict[str, str]], + temperature: float | None, + max_tokens: int, + retries: int, + extra_payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": _minimax_model_id(model), + "messages": messages, + } + clamped_temp = _minimax_clamp_temperature(temperature) + if clamped_temp is not None: + payload["temperature"] = clamped_temp + if max_tokens > 0: + payload["max_tokens"] = max_tokens + if extra_payload: + # MiniMax supports reasoning via the ``reasoning`` key (same + # schema as OpenRouter). Provider-specific keys like + # ``provider`` are silently dropped. + for key, value in extra_payload.items(): + if key in {"provider"}: + continue + payload[key] = value + encoded = json.dumps(payload).encode("utf-8") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + if retries < 1: + raise ValueError("retries must be >= 1") + + last_error: Exception | None = None + for attempt in range(1, retries + 1): + retry_after_header: str | None = None + retry_after_seconds: float | None = None + request = urllib.request.Request( + self.base_url, + data=encoded, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout_seconds) as resp: + raw = resp.read().decode("utf-8") + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise RuntimeError("MiniMax returned non-object JSON.") + # Strip blocks from the response text so + # that internal reasoning traces do not pollute benchmark + # answers or judge inputs. + choices = parsed.get("choices") + if isinstance(choices, list): + for choice in choices: + if not isinstance(choice, dict): + continue + msg = choice.get("message") + if isinstance(msg, dict) and isinstance(msg.get("content"), str): + msg["content"] = _strip_think_tags(msg["content"]) + return parsed + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + retry_after_header = exc.headers.get("Retry-After") if exc.headers else None + retry_after_seconds = parse_retry_after_seconds(retry_after_header) + retryable = is_retryable_http_status(exc.code) + last_error = MiniMaxAPIError( + f"HTTP {exc.code} from MiniMax (attempt {attempt}/{retries})" + f"{' [retryable]' if retryable else ' [non-retryable]'}: {detail}" + + ( + f" (retry_after_seconds={retry_after_seconds})" + if retry_after_seconds is not None + else "" + ), + status_code=exc.code, + retryable=retryable, + retry_after_seconds=retry_after_seconds, + ) + if not retryable: + raise last_error from exc + except Exception as exc: # pylint: disable=broad-except + last_error = RuntimeError( + f"MiniMax call failed (attempt {attempt}/{retries}): {exc}" + ) + + if attempt < retries: + time.sleep(compute_retry_delay_seconds(attempt, retry_after_header)) + + assert last_error is not None + raise last_error + + def extract_model_text(api_response: dict[str, Any]) -> str: if api_response.get("error"): err = api_response.get("error") @@ -3016,6 +3150,17 @@ def run_collect(args: argparse.Namespace) -> int: project_id=openai_project_id, organization_id=openai_organization_id, ) + if "minimax" in providers_in_use: + minimax_key = os.getenv("MINIMAX_API_KEY", "").strip() + if not minimax_key: + raise RuntimeError( + "MINIMAX_API_KEY is required for models routed to minimax " + "unless --dry-run is set." + ) + clients["minimax"] = MiniMaxClient( + api_key=minimax_key, + timeout_seconds=args.timeout_seconds, + ) started = time.perf_counter() records: list[dict[str, Any]] = list(checkpoint_records) @@ -4115,6 +4260,17 @@ def run_grade(args: argparse.Namespace) -> int: project_id=openai_project_id, organization_id=openai_organization_id, ) + elif judge_provider == "minimax": + minimax_key = os.getenv("MINIMAX_API_KEY", "").strip() + if not minimax_key: + raise RuntimeError( + "MINIMAX_API_KEY is required for judge models routed to minimax " + "unless --dry-run is set." + ) + clients["minimax"] = MiniMaxClient( + api_key=minimax_key, + timeout_seconds=args.timeout_seconds, + ) started = time.perf_counter() grade_rows: list[dict[str, Any]] = list(checkpoint_rows) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 0000000..9210be3 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,564 @@ +"""Unit and integration tests for the MiniMax direct provider. + +Tests cover: + - MiniMaxClient initialization and validation + - Model ID stripping (minimax/ prefix removal) + - Temperature clamping to (0.0, 1.0] + - Think-tag stripping from response text + - Provider alias and value registration + - Provider resolution with minimax/* wildcard + - MiniMaxAPIError classification + - Config loading with minimax provider routing + - Client wiring in collect/grade flows (dry-run) +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest +from unittest.mock import MagicMock, patch +from typing import Any +from http.server import HTTPServer, BaseHTTPRequestHandler +import threading + +# Ensure the scripts directory is importable +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"), +) + +import openrouter_benchmark as bench + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxModelId(unittest.TestCase): + """Test _minimax_model_id prefix stripping.""" + + def test_strips_minimax_prefix(self): + self.assertEqual(bench._minimax_model_id("minimax/minimax-m2.7"), "minimax-m2.7") + + def test_strips_minimax_prefix_highspeed(self): + self.assertEqual( + bench._minimax_model_id("minimax/minimax-m2.7-highspeed"), + "minimax-m2.7-highspeed", + ) + + def test_no_prefix_passthrough(self): + self.assertEqual(bench._minimax_model_id("minimax-m2.7"), "minimax-m2.7") + + def test_other_prefix_passthrough(self): + self.assertEqual(bench._minimax_model_id("openai/gpt-4"), "openai/gpt-4") + + def test_empty_after_prefix(self): + self.assertEqual(bench._minimax_model_id("minimax/"), "minimax/") + + def test_whitespace_stripped(self): + self.assertEqual(bench._minimax_model_id(" minimax/minimax-m2.7 "), "minimax-m2.7") + + +class TestMiniMaxClampTemperature(unittest.TestCase): + """Test _minimax_clamp_temperature enforces (0.0, 1.0].""" + + def test_none_passthrough(self): + self.assertIsNone(bench._minimax_clamp_temperature(None)) + + def test_valid_temperature(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(0.5), 0.5) + + def test_clamp_zero_to_min(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(0.0), 0.01) + + def test_clamp_negative_to_min(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(-1.0), 0.01) + + def test_clamp_above_one(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(2.0), 1.0) + + def test_exactly_one(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(1.0), 1.0) + + def test_small_positive(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(0.01), 0.01) + + def test_very_small_clamped(self): + self.assertAlmostEqual(bench._minimax_clamp_temperature(0.001), 0.01) + + +class TestStripThinkTags(unittest.TestCase): + """Test _strip_think_tags removes internal reasoning blocks.""" + + def test_no_think_tags(self): + self.assertEqual(bench._strip_think_tags("Hello world"), "Hello world") + + def test_single_think_block(self): + text = "internal reasoningThe answer is 42." + self.assertEqual(bench._strip_think_tags(text), "The answer is 42.") + + def test_multiline_think_block(self): + text = "\nStep 1: consider\nStep 2: decide\n\nFinal answer." + self.assertEqual(bench._strip_think_tags(text), "Final answer.") + + def test_multiple_think_blocks(self): + text = "firstHello secondworld" + self.assertEqual(bench._strip_think_tags(text), "Hello world") + + def test_empty_think_block(self): + text = "Result" + self.assertEqual(bench._strip_think_tags(text), "Result") + + def test_only_think_block(self): + text = "all reasoning" + self.assertEqual(bench._strip_think_tags(text), "") + + +class TestProviderRegistration(unittest.TestCase): + """Test that minimax is registered as a valid provider.""" + + def test_minimax_in_aliases(self): + self.assertIn("minimax", bench.MODEL_PROVIDER_ALIASES) + self.assertEqual(bench.MODEL_PROVIDER_ALIASES["minimax"], "minimax") + + def test_minimax_in_values(self): + self.assertIn("minimax", bench.MODEL_PROVIDER_VALUES) + + def test_normalize_minimax(self): + self.assertEqual( + bench.normalize_model_provider("minimax", field_name="test"), + "minimax", + ) + + def test_normalize_minimax_case_insensitive(self): + self.assertEqual( + bench.normalize_model_provider("MiniMax", field_name="test"), + "minimax", + ) + + +class TestProviderResolution(unittest.TestCase): + """Test resolve_model_provider with minimax/* wildcard routing.""" + + def test_wildcard_routing(self): + overrides = {"*": "openrouter", "minimax/*": "minimax"} + result = bench.resolve_model_provider("minimax/minimax-m2.7", overrides) + self.assertEqual(result, "minimax") + + def test_wildcard_routing_highspeed(self): + overrides = {"*": "openrouter", "minimax/*": "minimax"} + result = bench.resolve_model_provider( + "minimax/minimax-m2.7-highspeed", overrides + ) + self.assertEqual(result, "minimax") + + def test_exact_match_overrides_wildcard(self): + overrides = { + "*": "openrouter", + "minimax/*": "minimax", + "minimax/minimax-m2.7": "openrouter", + } + result = bench.resolve_model_provider("minimax/minimax-m2.7", overrides) + self.assertEqual(result, "openrouter") + + def test_non_minimax_model_uses_default(self): + overrides = {"*": "openrouter", "minimax/*": "minimax"} + result = bench.resolve_model_provider("anthropic/claude-sonnet-4.6", overrides) + self.assertEqual(result, "openrouter") + + +class TestParseModelProviders(unittest.TestCase): + """Test parse_model_providers with minimax entries.""" + + def test_parse_minimax_provider(self): + raw = '{"*": "openrouter", "minimax/*": "minimax"}' + result = bench.parse_model_providers(raw, field_name="test") + self.assertEqual(result["minimax/*"], "minimax") + + def test_parse_dict_input(self): + raw = {"*": "openrouter", "minimax/*": "minimax"} + result = bench.parse_model_providers(raw, field_name="test") + self.assertEqual(result["minimax/*"], "minimax") + + +class TestMiniMaxAPIError(unittest.TestCase): + """Test MiniMaxAPIError inherits from ProviderAPIError.""" + + def test_is_provider_error(self): + err = bench.MiniMaxAPIError( + "test error", status_code=429, retryable=True + ) + self.assertIsInstance(err, bench.ProviderAPIError) + self.assertEqual(err.status_code, 429) + self.assertTrue(err.retryable) + + def test_non_retryable(self): + err = bench.MiniMaxAPIError( + "bad request", status_code=400, retryable=False + ) + self.assertFalse(err.retryable) + + +class TestMiniMaxClientInit(unittest.TestCase): + """Test MiniMaxClient initialization and validation.""" + + def test_valid_init(self): + client = bench.MiniMaxClient(api_key="test-key", timeout_seconds=30) + self.assertEqual(client.api_key, "test-key") + self.assertEqual(client.timeout_seconds, 30) + self.assertEqual(client.base_url, "https://api.minimax.io/v1/chat/completions") + + def test_invalid_timeout(self): + with self.assertRaises(ValueError): + bench.MiniMaxClient(api_key="test-key", timeout_seconds=0) + + def test_negative_timeout(self): + with self.assertRaises(ValueError): + bench.MiniMaxClient(api_key="test-key", timeout_seconds=-1) + + +class TestMiniMaxClientChat(unittest.TestCase): + """Test MiniMaxClient.chat payload construction and response handling.""" + + def _make_client(self) -> bench.MiniMaxClient: + return bench.MiniMaxClient(api_key="test-key", timeout_seconds=30) + + @patch("urllib.request.urlopen") + def test_basic_chat_payload(self, mock_urlopen): + """Verify the outgoing payload structure.""" + response_body = json.dumps( + { + "id": "test-id", + "choices": [ + {"message": {"content": "Test response"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + } + ).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + client = self._make_client() + result = client.chat( + model="minimax/minimax-m2.7", + messages=[{"role": "user", "content": "Hello"}], + temperature=0.7, + max_tokens=100, + retries=1, + ) + + self.assertEqual(result["id"], "test-id") + # Verify the request was made with correct URL + call_args = mock_urlopen.call_args + request_obj = call_args[0][0] + self.assertEqual(request_obj.full_url, "https://api.minimax.io/v1/chat/completions") + sent_payload = json.loads(request_obj.data) + self.assertEqual(sent_payload["model"], "minimax-m2.7") + self.assertAlmostEqual(sent_payload["temperature"], 0.7) + self.assertEqual(sent_payload["max_tokens"], 100) + + @patch("urllib.request.urlopen") + def test_temperature_clamping_in_payload(self, mock_urlopen): + """Verify temperature=0 is clamped to 0.01.""" + response_body = json.dumps( + { + "id": "test-id", + "choices": [ + {"message": {"content": "response"}, "finish_reason": "stop"} + ], + "usage": {}, + } + ).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + client = self._make_client() + client.chat( + model="minimax-m2.7", + messages=[{"role": "user", "content": "Hi"}], + temperature=0.0, + max_tokens=0, + retries=1, + ) + + sent_payload = json.loads(mock_urlopen.call_args[0][0].data) + self.assertAlmostEqual(sent_payload["temperature"], 0.01) + + @patch("urllib.request.urlopen") + def test_think_tag_stripping_in_response(self, mock_urlopen): + """Verify tags are stripped from response content.""" + response_body = json.dumps( + { + "id": "test-id", + "choices": [ + { + "message": { + "content": "reasoning hereThe actual answer." + }, + "finish_reason": "stop", + } + ], + "usage": {}, + } + ).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + client = self._make_client() + result = client.chat( + model="minimax-m2.7", + messages=[{"role": "user", "content": "Test"}], + temperature=None, + max_tokens=0, + retries=1, + ) + + self.assertEqual(result["choices"][0]["message"]["content"], "The actual answer.") + + @patch("urllib.request.urlopen") + def test_provider_key_dropped(self, mock_urlopen): + """Verify the 'provider' key from extra_payload is dropped.""" + response_body = json.dumps( + { + "id": "test-id", + "choices": [ + {"message": {"content": "ok"}, "finish_reason": "stop"} + ], + "usage": {}, + } + ).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + client = self._make_client() + client.chat( + model="minimax-m2.7", + messages=[{"role": "user", "content": "Hi"}], + temperature=0.5, + max_tokens=0, + retries=1, + extra_payload={"provider": {"require_parameters": True}, "reasoning": {"effort": "high"}}, + ) + + sent_payload = json.loads(mock_urlopen.call_args[0][0].data) + self.assertNotIn("provider", sent_payload) + self.assertEqual(sent_payload["reasoning"], {"effort": "high"}) + + def test_invalid_retries(self): + client = self._make_client() + with self.assertRaises(ValueError): + client.chat( + model="minimax-m2.7", + messages=[], + temperature=None, + max_tokens=0, + retries=0, + ) + + @patch("urllib.request.urlopen") + def test_null_temperature_omitted(self, mock_urlopen): + """Verify temperature=None means no temperature key in payload.""" + response_body = json.dumps( + { + "id": "test-id", + "choices": [ + {"message": {"content": "ok"}, "finish_reason": "stop"} + ], + "usage": {}, + } + ).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + client = self._make_client() + client.chat( + model="minimax-m2.7", + messages=[{"role": "user", "content": "Hi"}], + temperature=None, + max_tokens=0, + retries=1, + ) + + sent_payload = json.loads(mock_urlopen.call_args[0][0].data) + self.assertNotIn("temperature", sent_payload) + + +class TestConfigMiniMaxRouting(unittest.TestCase): + """Test that config files route minimax/* models to minimax provider.""" + + def test_config_json(self): + import pathlib + config_path = ( + pathlib.Path(__file__).resolve().parent.parent / "config.json" + ) + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + models = config["collect"]["models"] + providers = config["collect"]["model_providers"] + self.assertIn("minimax/minimax-m2.7", models) + self.assertIn("minimax/minimax-m2.7-highspeed", models) + self.assertEqual(providers.get("minimax/*"), "minimax") + + def test_config_v2_json(self): + import pathlib + config_path = ( + pathlib.Path(__file__).resolve().parent.parent / "config.v2.json" + ) + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + models = config["collect"]["models"] + providers = config["collect"]["model_providers"] + self.assertIn("minimax/minimax-m2.7", models) + self.assertIn("minimax/minimax-m2.7-highspeed", models) + self.assertEqual(providers.get("minimax/*"), "minimax") + + def test_reasoning_efforts_defined(self): + import pathlib + config_path = ( + pathlib.Path(__file__).resolve().parent.parent / "config.json" + ) + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + efforts = config["collect"]["model_reasoning_efforts"] + self.assertIn("minimax/minimax-m2.7", efforts) + self.assertIn("minimax/minimax-m2.7-highspeed", efforts) + self.assertEqual(efforts["minimax/minimax-m2.7"], ["none", "high"]) + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +class _FakeMiniMaxHandler(BaseHTTPRequestHandler): + """Minimal HTTP handler mimicking the MiniMax chat/completions API.""" + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + req = json.loads(body) + model = req.get("model", "unknown") + response = { + "id": "integration-test-id", + "object": "chat.completion", + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": ( + "internal reasoning" + f"Integration test response from {model}" + ), + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 10, + "total_tokens": 25, + }, + } + payload = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + pass # suppress log output during tests + + +class TestMiniMaxClientIntegration(unittest.TestCase): + """Integration tests using a local HTTP server to simulate MiniMax API.""" + + @classmethod + def setUpClass(cls): + cls.server = HTTPServer(("127.0.0.1", 0), _FakeMiniMaxHandler) + cls.port = cls.server.server_address[1] + cls.server_thread = threading.Thread(target=cls.server.serve_forever) + cls.server_thread.daemon = True + cls.server_thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server_thread.join(timeout=5) + + def _make_client(self) -> bench.MiniMaxClient: + client = bench.MiniMaxClient(api_key="test-key", timeout_seconds=10) + client.base_url = f"http://127.0.0.1:{self.port}/v1/chat/completions" + return client + + def test_full_chat_roundtrip(self): + """End-to-end test: send chat request, get response, think tags stripped.""" + client = self._make_client() + result = client.chat( + model="minimax/minimax-m2.7", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is 2+2?"}, + ], + temperature=0.7, + max_tokens=100, + retries=1, + ) + + self.assertEqual(result["id"], "integration-test-id") + self.assertEqual(result["model"], "minimax-m2.7") + content = result["choices"][0]["message"]["content"] + # Think tags should be stripped + self.assertNotIn("", content) + self.assertIn("Integration test response from minimax-m2.7", content) + self.assertEqual(result["usage"]["total_tokens"], 25) + + def test_temperature_zero_clamped(self): + """Verify temperature=0 is clamped in actual request.""" + client = self._make_client() + result = client.chat( + model="minimax-m2.7-highspeed", + messages=[{"role": "user", "content": "Test"}], + temperature=0.0, + max_tokens=50, + retries=1, + ) + # Should succeed — the clamped temperature (0.01) is valid + self.assertEqual(result["model"], "minimax-m2.7-highspeed") + + def test_reasoning_effort_forwarded(self): + """Verify reasoning effort is forwarded via extra_payload.""" + client = self._make_client() + result = client.chat( + model="minimax-m2.7", + messages=[{"role": "user", "content": "Think hard about this."}], + temperature=0.5, + max_tokens=200, + retries=1, + extra_payload={"reasoning": {"effort": "high"}}, + ) + self.assertEqual(result["id"], "integration-test-id") + + +if __name__ == "__main__": + unittest.main()