diff --git a/README.md b/README.md index e7768888..39f3f0a5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ uv sync # core framework uv sync --extra server # + FastAPI server ``` -You also need a local inference backend: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), or [llama.cpp](https://github.com/ggerganov/llama.cpp). +You also need a local inference backend: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), or [llama.cpp](https://github.com/ggerganov/llama.cpp). Alternatively, use the `cloud` engine with [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google Gemini](https://ai.google.dev), [OpenRouter](https://openrouter.ai), or [MiniMax](https://www.minimax.io) by setting the corresponding API key environment variable. ## Quick Start diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index a4f313c2..19083b35 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -140,7 +140,7 @@ max_tokens = 1024 | `checkpoint_path` | string | `""` | Path to a fine-tuned checkpoint or LoRA adapter directory. | | `quantization` | string | `"none"` | Quantization format. Accepted values: `none`, `fp8`, `int8`, `int4`, `gguf_q4`, `gguf_q8`. | | `preferred_engine` | string | `""` | Override engine for this model (e.g., `"vllm"`). Takes priority over `engine.default`. | -| `provider` | string | `""` | Model provider hint: `local`, `openai`, `anthropic`, `google`. Used by the Cloud engine to route API calls. | +| `provider` | string | `""` | Model provider hint: `local`, `openai`, `anthropic`, `google`, `minimax`. Used by the Cloud engine to route API calls. | **Generation default fields** (overridable per-call): @@ -972,6 +972,7 @@ OpenJarvis respects the following environment variables: | `OPENAI_API_KEY` | API key for OpenAI cloud inference. Required for the `cloud` engine with OpenAI models. | | `ANTHROPIC_API_KEY` | API key for Anthropic cloud inference. Required for the `cloud` engine with Claude models. | | `GOOGLE_API_KEY` | API key for Google Gemini inference. Required for the `google` engine. | +| `MINIMAX_API_KEY` | API key for MiniMax cloud inference. Required for the `cloud` engine with MiniMax models (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed). | | `TAVILY_API_KEY` | API key for the Tavily web search tool. Required for the `web_search` tool. | ## Next Steps diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py index 8181a4b7..e5324ab6 100644 --- a/src/openjarvis/engine/cloud.py +++ b/src/openjarvis/engine/cloud.py @@ -1,4 +1,4 @@ -"""Cloud inference engine — OpenAI, Anthropic, and Google API backends.""" +"""Cloud inference engine — OpenAI, Anthropic, Google, and MiniMax API backends.""" from __future__ import annotations @@ -38,6 +38,10 @@ PRICING: Dict[str, tuple[float, float]] = { "gemini-3.1-flash-lite-preview": (0.30, 2.50), "gemini-3-flash-preview": (0.50, 3.00), "claude-haiku-4-5-20251001": (1.00, 5.00), + "MiniMax-M2.7": (0.30, 1.20), + "MiniMax-M2.7-highspeed": (0.60, 2.40), + "MiniMax-M2.5": (0.30, 1.20), + "MiniMax-M2.5-highspeed": (0.60, 2.40), } # Well-known model IDs per provider @@ -62,6 +66,12 @@ _GOOGLE_MODELS = [ "gemini-3.1-flash-lite-preview", "gemini-3-flash-preview", ] +_MINIMAX_MODELS = [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", +] # OpenRouter models — prefixed with "openrouter/" so they can be identified _OPENROUTER_POPULAR = [ @@ -76,6 +86,10 @@ _OPENROUTER_POPULAR = [ ] +def _is_minimax_model(model: str) -> bool: + return model.lower().startswith("minimax") + + def _is_openrouter_model(model: str) -> bool: return model.startswith("openrouter/") @@ -170,7 +184,7 @@ def _convert_tools_to_google( @EngineRegistry.register("cloud") class CloudEngine(InferenceEngine): - """Cloud inference via OpenAI, Anthropic, and Google SDKs.""" + """Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs.""" engine_id = "cloud" @@ -179,6 +193,7 @@ class CloudEngine(InferenceEngine): self._anthropic_client: Any = None self._google_client: Any = None self._openrouter_client: Any = None + self._minimax_client: Any = None self._init_clients() def _init_clients(self) -> None: @@ -214,6 +229,16 @@ class CloudEngine(InferenceEngine): ) except ImportError: pass + minimax_key = os.environ.get("MINIMAX_API_KEY") + if minimax_key: + try: + import openai + self._minimax_client = openai.OpenAI( + base_url="https://api.minimax.io/v1", + api_key=minimax_key, + ) + except ImportError: + pass def _generate_openai( self, @@ -626,6 +651,59 @@ class CloudEngine(InferenceEngine): "ttft": elapsed, } + def _generate_minimax( + self, + messages: Sequence[Message], + *, + model: str, + temperature: float, + max_tokens: int, + **kwargs: Any, + ) -> Dict[str, Any]: + if self._minimax_client is None: + raise EngineConnectionError( + "MiniMax client not available — set MINIMAX_API_KEY" + ) + # MiniMax requires temperature in (0.0, 1.0]; clamp zero + temperature = max(temperature, 0.01) + temperature = min(temperature, 1.0) + kwargs.pop("response_format", None) + create_kwargs: Dict[str, Any] = { + "model": model, + "messages": messages_to_dicts(messages), + "max_tokens": max_tokens, + "temperature": temperature, + } + t0 = time.monotonic() + resp = self._minimax_client.chat.completions.create(**create_kwargs) + elapsed = time.monotonic() - t0 + choice = resp.choices[0] + usage = resp.usage + prompt_tokens = usage.prompt_tokens if usage else 0 + completion_tokens = usage.completion_tokens if usage else 0 + result: Dict[str, Any] = { + "content": choice.message.content or "", + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": (usage.total_tokens if usage else 0), + }, + "model": resp.model, + "finish_reason": choice.finish_reason or "stop", + "cost_usd": estimate_cost(model, prompt_tokens, completion_tokens), + "ttft": elapsed, + } + if hasattr(choice.message, "tool_calls") and choice.message.tool_calls: + result["tool_calls"] = [ + { + "id": tc.id, + "name": tc.function.name, + "arguments": tc.function.arguments, + } + for tc in choice.message.tool_calls + ] + return result + def generate( self, messages: Sequence[Message], @@ -643,6 +721,8 @@ class CloudEngine(InferenceEngine): ) if _is_openrouter_model(model): return self._generate_openrouter(messages, **kw) + if _is_minimax_model(model): + return self._generate_minimax(messages, **kw) if _is_anthropic_model(model): return self._generate_anthropic(messages, **kw) if _is_google_model(model): @@ -669,6 +749,11 @@ class CloudEngine(InferenceEngine): messages, **kw ): yield token + elif _is_minimax_model(model): + async for token in self._stream_minimax( + messages, **kw + ): + yield token elif _is_anthropic_model(model): async for token in self._stream_anthropic( messages, **kw @@ -804,6 +889,32 @@ class CloudEngine(InferenceEngine): if delta and delta.content: yield delta.content + async def _stream_minimax( + self, + messages: Sequence[Message], + *, + model: str, + temperature: float, + max_tokens: int, + **kwargs: Any, + ) -> AsyncIterator[str]: + if self._minimax_client is None: + raise EngineConnectionError("MiniMax client not available") + temperature = max(temperature, 0.01) + temperature = min(temperature, 1.0) + create_kwargs: Dict[str, Any] = { + "model": model, + "messages": messages_to_dicts(messages), + "max_tokens": max_tokens, + "temperature": temperature, + "stream": True, + } + resp = self._minimax_client.chat.completions.create(**create_kwargs) + for chunk in resp: + delta = chunk.choices[0].delta if chunk.choices else None + if delta and delta.content: + yield delta.content + def list_models(self) -> List[str]: models: List[str] = [] if self._openai_client is not None: @@ -814,6 +925,8 @@ class CloudEngine(InferenceEngine): models.extend(_GOOGLE_MODELS) if self._openrouter_client is not None: models.extend(_OPENROUTER_POPULAR) + if self._minimax_client is not None: + models.extend(_MINIMAX_MODELS) return models def health(self) -> bool: @@ -822,6 +935,7 @@ class CloudEngine(InferenceEngine): or self._anthropic_client is not None or self._google_client is not None or self._openrouter_client is not None + or self._minimax_client is not None ) def close(self) -> None: @@ -839,6 +953,10 @@ class CloudEngine(InferenceEngine): if hasattr(self._openrouter_client, "close"): self._openrouter_client.close() self._openrouter_client = None + if self._minimax_client is not None: + if hasattr(self._minimax_client, "close"): + self._minimax_client.close() + self._minimax_client = None __all__ = ["CloudEngine", "PRICING", "_annotate_anthropic_cache", "estimate_cost"] diff --git a/src/openjarvis/intelligence/model_catalog.py b/src/openjarvis/intelligence/model_catalog.py index 2dfcf65d..d77c1740 100644 --- a/src/openjarvis/intelligence/model_catalog.py +++ b/src/openjarvis/intelligence/model_catalog.py @@ -707,6 +707,69 @@ BUILTIN_MODELS: List[ModelSpec] = [ }, ), # ----------------------------------------------------------------------- + # Cloud models — MiniMax + # ----------------------------------------------------------------------- + ModelSpec( + model_id="MiniMax-M2.7", + name="MiniMax M2.7", + parameter_count_b=0.0, + context_length=204800, + supported_engines=("cloud",), + provider="minimax", + requires_api_key=True, + metadata={ + "architecture": "proprietary", + "pricing_input": 0.30, + "pricing_output": 1.20, + "url": "https://www.minimax.io", + }, + ), + ModelSpec( + model_id="MiniMax-M2.7-highspeed", + name="MiniMax M2.7 Highspeed", + parameter_count_b=0.0, + context_length=204800, + supported_engines=("cloud",), + provider="minimax", + requires_api_key=True, + metadata={ + "architecture": "proprietary", + "pricing_input": 0.60, + "pricing_output": 2.40, + "url": "https://www.minimax.io", + }, + ), + ModelSpec( + model_id="MiniMax-M2.5", + name="MiniMax M2.5", + parameter_count_b=0.0, + context_length=204800, + supported_engines=("cloud",), + provider="minimax", + requires_api_key=True, + metadata={ + "architecture": "proprietary", + "pricing_input": 0.30, + "pricing_output": 1.20, + "url": "https://www.minimax.io", + }, + ), + ModelSpec( + model_id="MiniMax-M2.5-highspeed", + name="MiniMax M2.5 Highspeed", + parameter_count_b=0.0, + context_length=204800, + supported_engines=("cloud",), + provider="minimax", + requires_api_key=True, + metadata={ + "architecture": "proprietary", + "pricing_input": 0.60, + "pricing_output": 2.40, + "url": "https://www.minimax.io", + }, + ), + # ----------------------------------------------------------------------- # Cloud models — Google # ----------------------------------------------------------------------- ModelSpec( diff --git a/tests/engine/test_cloud_minimax.py b/tests/engine/test_cloud_minimax.py new file mode 100644 index 00000000..5d1ef5d9 --- /dev/null +++ b/tests/engine/test_cloud_minimax.py @@ -0,0 +1,331 @@ +"""Tests for MiniMax cloud provider support.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest import mock + +import pytest + +from openjarvis.core.registry import EngineRegistry +from openjarvis.core.types import Message, Role +from openjarvis.engine._base import EngineConnectionError +from openjarvis.engine.cloud import ( + _MINIMAX_MODELS, + PRICING, + CloudEngine, + _is_minimax_model, + estimate_cost, +) + + +def _make_cloud_engine(monkeypatch: pytest.MonkeyPatch) -> CloudEngine: + """Create a CloudEngine with all API keys cleared.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) + if not EngineRegistry.contains("cloud"): + EngineRegistry.register_value("cloud", CloudEngine) + return CloudEngine() + + +def _fake_minimax_response( + content: str = "Hello from MiniMax!", + model: str = "MiniMax-M2.5", + prompt_tokens: int = 10, + completion_tokens: int = 5, + tool_calls: list | None = None, +) -> SimpleNamespace: + usage = SimpleNamespace( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ) + message = SimpleNamespace(content=content, tool_calls=tool_calls) + choice = SimpleNamespace(message=message, finish_reason="stop") + return SimpleNamespace(choices=[choice], usage=usage, model=model) + + +# --------------------------------------------------------------------------- +# Routing tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxRouting: + def test_is_minimax_model(self) -> None: + assert _is_minimax_model("MiniMax-M2.7") is True + assert _is_minimax_model("MiniMax-M2.7-highspeed") is True + assert _is_minimax_model("MiniMax-M2.5") is True + assert _is_minimax_model("MiniMax-M2.5-highspeed") is True + assert _is_minimax_model("minimax-m2.7") is True + assert _is_minimax_model("gpt-4o") is False + assert _is_minimax_model("claude-opus-4-6") is False + assert _is_minimax_model("gemini-3-pro") is False + + def test_minimax_models_list(self) -> None: + assert "MiniMax-M2.7" in _MINIMAX_MODELS + assert "MiniMax-M2.7-highspeed" in _MINIMAX_MODELS + assert "MiniMax-M2.5" in _MINIMAX_MODELS + assert "MiniMax-M2.5-highspeed" in _MINIMAX_MODELS + + def test_m27_is_first_in_list(self) -> None: + assert _MINIMAX_MODELS[0] == "MiniMax-M2.7" + assert _MINIMAX_MODELS[1] == "MiniMax-M2.7-highspeed" + + +# --------------------------------------------------------------------------- +# Init tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxInit: + def test_init_with_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + fake_openai = mock.MagicMock() + with mock.patch.dict("sys.modules", {"openai": fake_openai}): + if not EngineRegistry.contains("cloud"): + EngineRegistry.register_value("cloud", CloudEngine) + engine = CloudEngine() + assert engine._minimax_client is not None + + def test_health_with_minimax_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + fake_openai = mock.MagicMock() + with mock.patch.dict("sys.modules", {"openai": fake_openai}): + engine = CloudEngine() + assert engine.health() is True + + def test_no_minimax_key_no_client(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + assert engine._minimax_client is None + + +# --------------------------------------------------------------------------- +# Generate tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxGenerate: + def test_m27_generate(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response( + content="I am MiniMax M2.7", model="MiniMax-M2.7" + ) + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.7" + ) + assert result["content"] == "I am MiniMax M2.7" + assert result["model"] == "MiniMax-M2.7" + assert result["usage"]["prompt_tokens"] == 10 + assert result["usage"]["completion_tokens"] == 5 + + def test_m27_highspeed_generate(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response( + content="I am MiniMax M2.7 Highspeed", model="MiniMax-M2.7-highspeed" + ) + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.7-highspeed" + ) + assert result["content"] == "I am MiniMax M2.7 Highspeed" + assert result["model"] == "MiniMax-M2.7-highspeed" + + def test_m25_generate(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response( + content="I am MiniMax M2.5", model="MiniMax-M2.5" + ) + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.5" + ) + assert result["content"] == "I am MiniMax M2.5" + assert result["model"] == "MiniMax-M2.5" + assert result["usage"]["prompt_tokens"] == 10 + assert result["usage"]["completion_tokens"] == 5 + + def test_m25_highspeed_generate(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response( + content="I am MiniMax M2.5 Highspeed", model="MiniMax-M2.5-highspeed" + ) + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.5-highspeed" + ) + assert result["content"] == "I am MiniMax M2.5 Highspeed" + assert result["model"] == "MiniMax-M2.5-highspeed" + + def test_temperature_clamped_above_zero( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """MiniMax requires temperature in (0.0, 1.0]; verify zero is clamped.""" + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response() + engine._minimax_client = fake_client + + engine.generate( + [Message(role=Role.USER, content="Hi")], + model="MiniMax-M2.5", + temperature=0.0, + ) + call_kwargs = fake_client.chat.completions.create.call_args + actual_temp = call_kwargs.kwargs.get("temperature") or call_kwargs[1].get( + "temperature" + ) + assert actual_temp >= 0.01, "Temperature should be clamped above zero" + + def test_temperature_clamped_at_max( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """MiniMax requires temperature <= 1.0; verify high values are clamped.""" + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response() + engine._minimax_client = fake_client + + engine.generate( + [Message(role=Role.USER, content="Hi")], + model="MiniMax-M2.5", + temperature=2.0, + ) + call_kwargs = fake_client.chat.completions.create.call_args + actual_temp = call_kwargs.kwargs.get("temperature") or call_kwargs[1].get( + "temperature" + ) + assert actual_temp <= 1.0, "Temperature should be clamped at 1.0" + + def test_tool_calls_extraction(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_tool_call = SimpleNamespace( + id="call_minimax_123", + type="function", + function=SimpleNamespace(name="search", arguments='{"q":"test"}'), + ) + fake_resp = _fake_minimax_response(content="", model="MiniMax-M2.5") + fake_resp.choices[0].message.tool_calls = [fake_tool_call] + + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = fake_resp + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Search")], model="MiniMax-M2.5" + ) + assert "tool_calls" in result + assert len(result["tool_calls"]) == 1 + tc = result["tool_calls"][0] + assert tc["id"] == "call_minimax_123" + assert tc["name"] == "search" + + def test_no_tool_calls_when_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + fake_client.chat.completions.create.return_value = _fake_minimax_response( + content="Just text" + ) + engine._minimax_client = fake_client + + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.5" + ) + assert "tool_calls" not in result + + def test_no_client_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + assert engine._minimax_client is None + + with pytest.raises( + EngineConnectionError, match="MiniMax client not available" + ): + engine.generate( + [Message(role=Role.USER, content="Hi")], model="MiniMax-M2.5" + ) + + +# --------------------------------------------------------------------------- +# Model discovery tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxModelDiscovery: + def test_list_models_includes_minimax( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + engine = _make_cloud_engine(monkeypatch) + engine._minimax_client = mock.MagicMock() + models = engine.list_models() + for m in _MINIMAX_MODELS: + assert m in models + + def test_only_minimax_client(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + engine._minimax_client = mock.MagicMock() + models = engine.list_models() + assert set(models) == set(_MINIMAX_MODELS) + + +# --------------------------------------------------------------------------- +# Pricing tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxPricing: + def test_minimax_models_in_pricing(self) -> None: + assert "MiniMax-M2.7" in PRICING + assert "MiniMax-M2.7-highspeed" in PRICING + assert "MiniMax-M2.5" in PRICING + assert "MiniMax-M2.5-highspeed" in PRICING + + def test_minimax_m27_cost_estimate(self) -> None: + # MiniMax-M2.7: $0.30/M in, $1.20/M out + cost = estimate_cost("MiniMax-M2.7", 1_000_000, 1_000_000) + assert cost == pytest.approx(1.50) + + def test_minimax_m27_highspeed_cost_estimate(self) -> None: + # MiniMax-M2.7-highspeed: $0.60/M in, $2.40/M out + cost = estimate_cost("MiniMax-M2.7-highspeed", 1_000_000, 1_000_000) + assert cost == pytest.approx(3.00) + + def test_minimax_m25_cost_estimate(self) -> None: + # MiniMax-M2.5: $0.30/M in, $1.20/M out + cost = estimate_cost("MiniMax-M2.5", 1_000_000, 1_000_000) + assert cost == pytest.approx(1.50) + + def test_zero_tokens_zero_cost(self) -> None: + assert estimate_cost("MiniMax-M2.7", 0, 0) == 0.0 + + +# --------------------------------------------------------------------------- +# Close tests +# --------------------------------------------------------------------------- + + +class TestMiniMaxClose: + def test_close_minimax_client(self, monkeypatch: pytest.MonkeyPatch) -> None: + engine = _make_cloud_engine(monkeypatch) + fake_client = mock.MagicMock() + engine._minimax_client = fake_client + engine.close() + assert engine._minimax_client is None + fake_client.close.assert_called_once() diff --git a/tests/integration/test_minimax_cloud.py b/tests/integration/test_minimax_cloud.py new file mode 100644 index 00000000..1cf0cec7 --- /dev/null +++ b/tests/integration/test_minimax_cloud.py @@ -0,0 +1,63 @@ +"""Integration test for MiniMax cloud provider. + +Requires MINIMAX_API_KEY environment variable to be set. +Run with: pytest tests/integration/test_minimax_cloud.py -v +""" + +from __future__ import annotations + +import os + +import pytest + +from openjarvis.core.registry import EngineRegistry +from openjarvis.core.types import Message, Role +from openjarvis.engine.cloud import CloudEngine + +_MINIMAX_KEY = os.environ.get("MINIMAX_API_KEY", "") +_skip_no_key = pytest.mark.skipif( + not _MINIMAX_KEY, + reason="MINIMAX_API_KEY not set", +) + + +@_skip_no_key +class TestMiniMaxCloudIntegration: + """Live integration tests against MiniMax Cloud API.""" + + @pytest.fixture() + def engine(self, monkeypatch: pytest.MonkeyPatch) -> CloudEngine: + monkeypatch.setenv("MINIMAX_API_KEY", _MINIMAX_KEY) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + if not EngineRegistry.contains("cloud"): + EngineRegistry.register_value("cloud", CloudEngine) + return CloudEngine() + + def test_m27_highspeed_basic_chat(self, engine: CloudEngine) -> None: + """Send a simple message via M2.7-highspeed and verify non-empty response.""" + result = engine.generate( + [Message(role=Role.USER, content="Reply with exactly: hello world")], + model="MiniMax-M2.7-highspeed", + temperature=0.01, + max_tokens=32, + ) + assert result["content"], "Expected non-empty content" + assert result["usage"]["prompt_tokens"] > 0 + assert result["usage"]["completion_tokens"] > 0 + assert result["finish_reason"] in ("stop", "length") + + def test_m27_highspeed_health(self, engine: CloudEngine) -> None: + """Engine health should be True when MINIMAX_API_KEY is set.""" + assert engine.health() is True + + def test_m27_highspeed_list_models(self, engine: CloudEngine) -> None: + """MiniMax models should appear in list_models.""" + models = engine.list_models() + assert "MiniMax-M2.7" in models + assert "MiniMax-M2.7-highspeed" in models + assert "MiniMax-M2.5" in models + assert "MiniMax-M2.5-highspeed" in models