mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-10-09 03:17:51 +00:00
feat: add MiniMax as first-class LLM provider
- Add MiniMax-specific temperature clamping (0.0, 1.0] in LLMClient - Remove unsupported response_format parameter for MiniMax models - Add MINIMAX_API_KEY auto-detection in resolver (Tier 3 fallback) - Add MiniMax model comment in nanobot provider registry - Add MiniMax to .env.example with usage instructions - Add MiniMax provider section in config/README.md - Add MiniMax usage example in main README.md - Add 26 unit tests + 3 integration tests
This commit is contained in:
parent
11bdf128d9
commit
36005e3958
9 changed files with 371 additions and 3 deletions
|
|
@ -209,6 +209,9 @@ openspace
|
|||
|
||||
# Execute task
|
||||
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
|
||||
|
||||
# Use MiniMax (high-performance, 204K context)
|
||||
openspace --model "minimax/MiniMax-M2.7" --query "Build a REST API with FastAPI"
|
||||
```
|
||||
|
||||
Add your own custom skills: [`openspace/skills/README.md`](openspace/skills/README.md).
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# MiniMax (for minimax/* models, e.g. minimax/MiniMax-M2.7)
|
||||
# MINIMAX_API_KEY=
|
||||
|
||||
# ── OpenSpace Cloud (optional) ──────────────────────────────
|
||||
# Register at https://open-space.cloud to get your key.
|
||||
# Enables cloud skill search & upload; local features work without it.
|
||||
|
|
|
|||
|
|
@ -113,3 +113,41 @@ Layered system — later files override earlier ones:
|
|||
| `sandbox_enabled` | Enable sandboxing for all operations | `false` |
|
||||
| Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global |
|
||||
|
||||
## 6. Supported LLM Providers
|
||||
|
||||
OpenSpace uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model routing. Set your model via `--model` flag, `OPENSPACE_MODEL` env var, or host agent config.
|
||||
|
||||
| Provider | Model format | API Key env var |
|
||||
|----------|-------------|-----------------|
|
||||
| OpenRouter | `openrouter/anthropic/claude-sonnet-4.5` | `OPENROUTER_API_KEY` |
|
||||
| Anthropic | `anthropic/claude-sonnet-4-5` | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` |
|
||||
| DeepSeek | `deepseek/deepseek-chat` | `DEEPSEEK_API_KEY` |
|
||||
| MiniMax | `minimax/MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||
|
||||
### MiniMax
|
||||
|
||||
[MiniMax](https://platform.minimax.io) offers high-performance LLMs with 204K context at competitive pricing.
|
||||
|
||||
**Available models:**
|
||||
|
||||
| Model | Context | Description |
|
||||
|-------|---------|-------------|
|
||||
| `MiniMax-M2.7` | 204K | Peak performance, ultimate value |
|
||||
| `MiniMax-M2.7-highspeed` | 204K | Same performance, faster and more agile |
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
```bash
|
||||
# Set your API key
|
||||
export MINIMAX_API_KEY=your-key-here
|
||||
|
||||
# Run with MiniMax
|
||||
openspace --model "minimax/MiniMax-M2.7" --query "your task"
|
||||
```
|
||||
|
||||
**API docs:** [OpenAI-compatible API](https://platform.minimax.io/docs/api-reference/text-openai-api)
|
||||
|
||||
> [!NOTE]
|
||||
> MiniMax temperature is automatically clamped to `(0.0, 1.0]` by OpenSpace. The `response_format` parameter is not supported and is automatically removed.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ PROVIDER_REGISTRY: List[tuple] = [
|
|||
("zhipu", ("zhipu", "glm", "zai"), ""),
|
||||
("dashscope", ("qwen", "dashscope"), ""),
|
||||
("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"),
|
||||
("minimax", ("minimax",), "https://api.minimax.io/v1"),
|
||||
("minimax", ("minimax",), "https://api.minimax.io/v1"), # MiniMax-M2.7, MiniMax-M2.7-highspeed
|
||||
("groq", ("groq",), ""),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,18 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
|||
if not resolved_model:
|
||||
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
|
||||
|
||||
# --- Tier 3: Provider-native env vars (MiniMax auto-detection) ---
|
||||
# If the model targets MiniMax but no api_key was set in Tier 1/2,
|
||||
# auto-detect MINIMAX_API_KEY from the environment.
|
||||
if "api_key" not in kwargs and resolved_model and "minimax" in resolved_model.lower():
|
||||
minimax_key = os.environ.get("MINIMAX_API_KEY")
|
||||
if minimax_key:
|
||||
kwargs["api_key"] = minimax_key
|
||||
if "api_base" not in kwargs:
|
||||
kwargs["api_base"] = "https://api.minimax.io/v1"
|
||||
source = "MINIMAX_API_KEY env"
|
||||
logger.info("Auto-detected MINIMAX_API_KEY for MiniMax model")
|
||||
|
||||
if kwargs:
|
||||
safe = {
|
||||
k: (v[:8] + "..." if k == "api_key" and isinstance(v, str) and len(v) > 8 else v)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,41 @@ litellm.suppress_debug_info = True
|
|||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
def _is_minimax_model(model: str) -> bool:
|
||||
"""Check if the model string refers to a MiniMax model."""
|
||||
return "minimax" in model.lower()
|
||||
|
||||
|
||||
def _apply_minimax_constraints(completion_kwargs: Dict) -> Dict:
|
||||
"""Apply MiniMax-specific parameter constraints.
|
||||
|
||||
MiniMax API constraints:
|
||||
- temperature must be in (0.0, 1.0] — cannot be 0
|
||||
- response_format is not supported — must be removed
|
||||
"""
|
||||
# Clamp temperature
|
||||
temp = completion_kwargs.get("temperature")
|
||||
if temp is not None:
|
||||
original = temp
|
||||
if temp <= 0:
|
||||
temp = 0.01
|
||||
elif temp > 1.0:
|
||||
temp = 1.0
|
||||
completion_kwargs["temperature"] = temp
|
||||
if temp != original:
|
||||
logger.debug(
|
||||
"MiniMax: clamped temperature %.4f -> %.4f (must be in (0, 1])",
|
||||
original, temp,
|
||||
)
|
||||
|
||||
# Remove unsupported response_format
|
||||
if "response_format" in completion_kwargs:
|
||||
completion_kwargs.pop("response_format")
|
||||
logger.debug("MiniMax: removed unsupported response_format parameter")
|
||||
|
||||
return completion_kwargs
|
||||
|
||||
|
||||
def _sanitize_schema(params: Dict) -> Dict:
|
||||
"""Sanitize tool parameter schema to comply with Claude API requirements.
|
||||
|
||||
|
|
@ -421,14 +456,19 @@ class LLMClient:
|
|||
|
||||
async def _call_with_retry(self, **completion_kwargs):
|
||||
"""Call LLM with backoff retry on rate limit errors
|
||||
|
||||
|
||||
Timeout and retry strategy:
|
||||
- Single call timeout: self.timeout (default 120s)
|
||||
- Rate limit retry delays: 60s, 90s, 120s
|
||||
- Total max time: timeout * max_retries + sum(retry_delays)
|
||||
"""
|
||||
# Apply MiniMax-specific parameter constraints before calling
|
||||
model = completion_kwargs.get("model", self.model)
|
||||
if _is_minimax_model(model):
|
||||
completion_kwargs = _apply_minimax_constraints(completion_kwargs)
|
||||
|
||||
last_exception = None
|
||||
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
# Add timeout to the completion call
|
||||
|
|
|
|||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
72
tests/test_minimax_integration.py
Normal file
72
tests/test_minimax_integration.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Integration tests for MiniMax provider support.
|
||||
|
||||
These tests require MINIMAX_API_KEY to be set in the environment.
|
||||
They are automatically skipped when the key is not available.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import unittest
|
||||
|
||||
MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY")
|
||||
SKIP_REASON = "MINIMAX_API_KEY not set"
|
||||
|
||||
|
||||
@unittest.skipUnless(MINIMAX_API_KEY, SKIP_REASON)
|
||||
class TestMiniMaxChatIntegration(unittest.TestCase):
|
||||
"""Integration tests for MiniMax chat completions via litellm."""
|
||||
|
||||
def test_basic_chat_completion(self):
|
||||
"""MiniMax M2.7 should return a valid chat completion."""
|
||||
import litellm
|
||||
|
||||
response = litellm.completion(
|
||||
model="minimax/MiniMax-M2.7",
|
||||
messages=[{"role": "user", "content": "What is 2+2? Reply with just the number."}],
|
||||
max_tokens=50,
|
||||
temperature=0.5,
|
||||
api_key=MINIMAX_API_KEY,
|
||||
)
|
||||
self.assertTrue(response.choices)
|
||||
content = response.choices[0].message.content
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
def test_highspeed_model(self):
|
||||
"""MiniMax M2.7-highspeed should also work."""
|
||||
import litellm
|
||||
|
||||
response = litellm.completion(
|
||||
model="minimax/MiniMax-M2.7-highspeed",
|
||||
messages=[{"role": "user", "content": "Reply with only the word 'ok'."}],
|
||||
max_tokens=10,
|
||||
temperature=0.5,
|
||||
api_key=MINIMAX_API_KEY,
|
||||
)
|
||||
self.assertTrue(response.choices)
|
||||
self.assertTrue(response.choices[0].message.content)
|
||||
|
||||
def test_llm_client_with_minimax(self):
|
||||
"""LLMClient should work with MiniMax models (temperature clamping applied)."""
|
||||
from openspace.llm.client import LLMClient
|
||||
|
||||
client = LLMClient(
|
||||
model="minimax/MiniMax-M2.7-highspeed",
|
||||
timeout=30.0,
|
||||
max_retries=1,
|
||||
api_key=MINIMAX_API_KEY,
|
||||
)
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
client.complete(
|
||||
messages=[{"role": "user", "content": "Reply with 'integration test passed'."}],
|
||||
temperature=0.5,
|
||||
max_tokens=30,
|
||||
)
|
||||
)
|
||||
self.assertIn("message", result)
|
||||
content = result["message"].get("content", "")
|
||||
self.assertTrue(content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
200
tests/test_minimax_provider.py
Normal file
200
tests/test_minimax_provider.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Unit tests for MiniMax provider support in OpenSpace."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from openspace.llm.client import (
|
||||
_is_minimax_model,
|
||||
_apply_minimax_constraints,
|
||||
)
|
||||
from openspace.host_detection.nanobot import match_provider, PROVIDER_REGISTRY
|
||||
from openspace.host_detection.resolver import build_llm_kwargs
|
||||
|
||||
|
||||
class TestIsMiniMaxModel(unittest.TestCase):
|
||||
"""Tests for MiniMax model detection."""
|
||||
|
||||
def test_minimax_prefixed_model(self):
|
||||
self.assertTrue(_is_minimax_model("minimax/MiniMax-M2.7"))
|
||||
|
||||
def test_minimax_highspeed_model(self):
|
||||
self.assertTrue(_is_minimax_model("minimax/MiniMax-M2.7-highspeed"))
|
||||
|
||||
def test_minimax_case_insensitive(self):
|
||||
self.assertTrue(_is_minimax_model("MiniMax/MiniMax-M2.7"))
|
||||
|
||||
def test_minimax_in_openai_compat(self):
|
||||
self.assertTrue(_is_minimax_model("openai/MiniMax-M2.7"))
|
||||
|
||||
def test_non_minimax_openai_model(self):
|
||||
self.assertFalse(_is_minimax_model("openai/gpt-4o"))
|
||||
|
||||
def test_non_minimax_anthropic_model(self):
|
||||
self.assertFalse(_is_minimax_model("anthropic/claude-sonnet-4-5"))
|
||||
|
||||
def test_non_minimax_openrouter_model(self):
|
||||
self.assertFalse(_is_minimax_model("openrouter/anthropic/claude-sonnet-4.5"))
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertFalse(_is_minimax_model(""))
|
||||
|
||||
|
||||
class TestApplyMiniMaxConstraints(unittest.TestCase):
|
||||
"""Tests for MiniMax parameter constraints."""
|
||||
|
||||
def test_clamp_temperature_zero(self):
|
||||
"""Temperature 0 should be clamped to 0.01."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7", "temperature": 0}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["temperature"], 0.01)
|
||||
|
||||
def test_clamp_temperature_negative(self):
|
||||
"""Negative temperature should be clamped to 0.01."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7", "temperature": -0.5}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["temperature"], 0.01)
|
||||
|
||||
def test_clamp_temperature_above_one(self):
|
||||
"""Temperature > 1.0 should be clamped to 1.0."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7", "temperature": 1.5}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["temperature"], 1.0)
|
||||
|
||||
def test_valid_temperature_unchanged(self):
|
||||
"""Valid temperature (0 < t <= 1.0) should remain unchanged."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7", "temperature": 0.7}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["temperature"], 0.7)
|
||||
|
||||
def test_temperature_one_unchanged(self):
|
||||
"""Temperature 1.0 is valid and should remain unchanged."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7", "temperature": 1.0}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["temperature"], 1.0)
|
||||
|
||||
def test_no_temperature_no_change(self):
|
||||
"""When temperature is not set, no clamping should occur."""
|
||||
kwargs = {"model": "minimax/MiniMax-M2.7"}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertNotIn("temperature", result)
|
||||
|
||||
def test_remove_response_format(self):
|
||||
"""response_format should be removed for MiniMax models."""
|
||||
kwargs = {
|
||||
"model": "minimax/MiniMax-M2.7",
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertNotIn("response_format", result)
|
||||
|
||||
def test_other_params_preserved(self):
|
||||
"""Non-constrained parameters should be preserved."""
|
||||
kwargs = {
|
||||
"model": "minimax/MiniMax-M2.7",
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
result = _apply_minimax_constraints(kwargs)
|
||||
self.assertEqual(result["max_tokens"], 1024)
|
||||
self.assertEqual(result["messages"], [{"role": "user", "content": "Hello"}])
|
||||
self.assertEqual(result["temperature"], 0.5)
|
||||
|
||||
|
||||
class TestProviderRegistry(unittest.TestCase):
|
||||
"""Tests for MiniMax in the nanobot provider registry."""
|
||||
|
||||
def test_minimax_in_registry(self):
|
||||
"""MiniMax should be in the provider registry."""
|
||||
names = [entry[0] for entry in PROVIDER_REGISTRY]
|
||||
self.assertIn("minimax", names)
|
||||
|
||||
def test_minimax_base_url(self):
|
||||
"""MiniMax should have the correct base URL."""
|
||||
for name, _keywords, base_url in PROVIDER_REGISTRY:
|
||||
if name == "minimax":
|
||||
self.assertEqual(base_url, "https://api.minimax.io/v1")
|
||||
break
|
||||
|
||||
def test_minimax_keyword_match(self):
|
||||
"""MiniMax keyword should match 'minimax'."""
|
||||
for name, keywords, _base_url in PROVIDER_REGISTRY:
|
||||
if name == "minimax":
|
||||
self.assertIn("minimax", keywords)
|
||||
break
|
||||
|
||||
def test_match_provider_minimax_model(self):
|
||||
"""match_provider should find minimax config for minimax models."""
|
||||
providers = {
|
||||
"minimax": {"apiKey": "test-key-123"},
|
||||
}
|
||||
result = match_provider(providers, "minimax/MiniMax-M2.7")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["api_key"], "test-key-123")
|
||||
self.assertEqual(result["api_base"], "https://api.minimax.io/v1")
|
||||
|
||||
def test_match_provider_minimax_keyword(self):
|
||||
"""match_provider should detect minimax in model name via keyword."""
|
||||
providers = {
|
||||
"minimax": {"apiKey": "test-key-456"},
|
||||
}
|
||||
result = match_provider(providers, "MiniMax-M2.7")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["api_key"], "test-key-456")
|
||||
|
||||
def test_match_provider_minimax_forced(self):
|
||||
"""match_provider should use minimax when forced_provider='minimax'."""
|
||||
providers = {
|
||||
"minimax": {"apiKey": "test-key-789"},
|
||||
"openai": {"apiKey": "other-key"},
|
||||
}
|
||||
result = match_provider(providers, "some-model", forced_provider="minimax")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["api_key"], "test-key-789")
|
||||
|
||||
|
||||
class TestResolverMiniMaxDetection(unittest.TestCase):
|
||||
"""Tests for MINIMAX_API_KEY auto-detection in the resolver."""
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "minimax-test-key"}, clear=False)
|
||||
@patch("openspace.host_detection.nanobot.try_read_nanobot_config", return_value=None)
|
||||
def test_auto_detect_minimax_api_key(self, _mock_nanobot):
|
||||
"""MINIMAX_API_KEY should be auto-detected for minimax models."""
|
||||
model, kwargs = build_llm_kwargs("minimax/MiniMax-M2.7")
|
||||
self.assertEqual(model, "minimax/MiniMax-M2.7")
|
||||
self.assertEqual(kwargs.get("api_key"), "minimax-test-key")
|
||||
self.assertEqual(kwargs.get("api_base"), "https://api.minimax.io/v1")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=False)
|
||||
@patch("openspace.host_detection.nanobot.try_read_nanobot_config", return_value=None)
|
||||
def test_no_minimax_key_no_kwargs(self, _mock_nanobot):
|
||||
"""Without MINIMAX_API_KEY, no api_key should be set."""
|
||||
# Remove MINIMAX_API_KEY if it exists
|
||||
os.environ.pop("MINIMAX_API_KEY", None)
|
||||
model, kwargs = build_llm_kwargs("minimax/MiniMax-M2.7")
|
||||
self.assertEqual(model, "minimax/MiniMax-M2.7")
|
||||
self.assertNotIn("api_key", kwargs)
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "minimax-key"}, clear=False)
|
||||
@patch("openspace.host_detection.nanobot.try_read_nanobot_config", return_value=None)
|
||||
def test_minimax_not_triggered_for_openai(self, _mock_nanobot):
|
||||
"""MINIMAX_API_KEY should not be used for non-minimax models."""
|
||||
model, kwargs = build_llm_kwargs("openai/gpt-4o")
|
||||
self.assertEqual(model, "openai/gpt-4o")
|
||||
self.assertNotIn("api_key", kwargs)
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"OPENSPACE_LLM_API_KEY": "explicit-key", "MINIMAX_API_KEY": "minimax-key"},
|
||||
clear=False,
|
||||
)
|
||||
@patch("openspace.host_detection.nanobot.try_read_nanobot_config", return_value=None)
|
||||
def test_explicit_key_overrides_minimax(self, _mock_nanobot):
|
||||
"""OPENSPACE_LLM_API_KEY (Tier 1) should override MINIMAX_API_KEY (Tier 3)."""
|
||||
model, kwargs = build_llm_kwargs("minimax/MiniMax-M2.7")
|
||||
self.assertEqual(kwargs["api_key"], "explicit-key")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue