Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions openspace/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions openspace/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

2 changes: 1 addition & 1 deletion openspace/host_detection/nanobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
("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",), ""),
]

Expand Down
12 changes: 12 additions & 0 deletions openspace/host_detection/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions openspace/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,41 @@
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.

Expand Down Expand Up @@ -421,14 +456,19 @@ async def _rate_limit(self):

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
Expand Down
Empty file added tests/__init__.py
Empty file.
72 changes: 72 additions & 0 deletions tests/test_minimax_integration.py
Original file line number Diff line number Diff line change
@@ -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()
Loading