mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
feat: add MiniMax as cloud inference provider with M2.7 default (#85)
* feat: add MiniMax as cloud inference provider Add MiniMax M2.5 and M2.5-highspeed as a 5th cloud provider alongside OpenAI, Anthropic, Google, and OpenRouter. Uses the OpenAI-compatible API at api.minimax.io/v1 via the existing openai SDK dependency. Changes: - Add MiniMax client init, generate, and streaming in CloudEngine - Add MiniMax models to model catalog with correct pricing - Add MINIMAX_API_KEY environment variable support - Add temperature clamping (0.01-1.0) per MiniMax API constraints - Add 19 unit tests and 3 integration tests - Update docs and README with MiniMax provider info * feat: upgrade MiniMax default model to M2.7 - Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list - Set MiniMax-M2.7 as default model (first in list) - Keep all previous models (M2.5, M2.5-highspeed) as alternatives - Update pricing table with M2.7 entries - Update model catalog with M2.7 specs - Update docs to list all available MiniMax models - Update unit tests (23 passing) and integration tests (3 passing) --------- Co-authored-by: Octopus <octo-patch@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user