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
38 changes: 38 additions & 0 deletions clawspring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour
| **Zhipu (GLM)** | `glm-4-flash` | 128k | Free tier available | `ZHIPU_API_KEY` |
| **DeepSeek** | `deepseek-chat` | 64k | Strong coding | `DEEPSEEK_API_KEY` |
| **DeepSeek** | `deepseek-reasoner` | 64k | Chain-of-thought reasoning | `DEEPSEEK_API_KEY` |
| **MiniMax** | `MiniMax-M3` | 1M | Multimodal coding and long-context tasks | `MINIMAX_API_KEY` |
| **MiniMax** | `MiniMax-M2.7` | 204.8k | Coding and agent workflows | `MINIMAX_API_KEY` |

### Open-Source (Local via Ollama)

Expand Down Expand Up @@ -542,6 +544,39 @@ clawspring --model deepseek/deepseek-chat
clawspring --model deepseek/deepseek-reasoner
```

### MiniMax

Get your API key at [platform.minimax.io](https://platform.minimax.io/docs). The built-in provider uses the global OpenAI-compatible endpoint by default.

```bash
export MINIMAX_API_KEY=...

clawspring --model MiniMax-M3
clawspring --model MiniMax-M2.7
```

For the CN OpenAI-compatible endpoint, use the existing custom adapter:

```bash
export CUSTOM_API_KEY="$MINIMAX_API_KEY"
export CUSTOM_BASE_URL=https://api.minimaxi.com/v1

clawspring --model custom/MiniMax-M3
```

For the Anthropic-compatible protocol, use the existing Anthropic adapter with the Base URL ending in `/anthropic`:

```bash
# Global
export ANTHROPIC_API_KEY="$MINIMAX_API_KEY"
export ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
clawspring --model anthropic/MiniMax-M3

# CN
export ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
clawspring --model anthropic/MiniMax-M3
```

---

## Usage: Open-Source Models (Local)
Expand Down Expand Up @@ -729,6 +764,7 @@ clawspring --model qwen:qwen-max
| `qwen`, `qwq-` | qwen |
| `glm-` | zhipu |
| `deepseek-` | deepseek |
| `minimax-` | minimax |
| `llama`, `mistral`, `phi`, `gemma`, `mixtral`, `codellama` | ollama |

---
Expand Down Expand Up @@ -869,6 +905,7 @@ export MOONSHOT_API_KEY=sk-... # Kimi
export DASHSCOPE_API_KEY=sk-... # Qwen
export ZHIPU_API_KEY=... # Zhipu GLM
export DEEPSEEK_API_KEY=sk-... # DeepSeek
export MINIMAX_API_KEY=... # MiniMax
```

### Method 2: Set Inside the REPL (persisted)
Expand All @@ -881,6 +918,7 @@ export DEEPSEEK_API_KEY=sk-... # DeepSeek
/config qwen_api_key=sk-...
/config zhipu_api_key=...
/config deepseek_api_key=sk-...
/config minimax_api_key=...
```

Keys are saved to `~/.clawspring/config.json` and loaded automatically on next launch.
Expand Down
4 changes: 1 addition & 3 deletions clawspring/compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ def get_context_limit(model: str) -> int:
Returns:
context limit in tokens
"""
provider_name = providers.detect_provider(model)
prov = providers.PROVIDERS.get(provider_name, {})
return prov.get("context_limit", 128000)
return providers.get_context_limit(model)


# ── Layer 1: Snip old tool results ────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions clawspring/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
# "qwen_api_key": "..."
# "zhipu_api_key": "..."
# "deepseek_api_key": "..."
# "minimax_api_key": "..."
}


Expand Down
74 changes: 73 additions & 1 deletion clawspring/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
qwen — Alibaba DashScope (qwen-max, qwen-plus, ...)
zhipu — Zhipu GLM (glm-4, glm-4-plus, ...)
deepseek — DeepSeek (deepseek-chat, deepseek-reasoner, ...)
minimax — MiniMax (MiniMax-M3, MiniMax-M2.7)
ollama — Local Ollama (llama3.3, qwen2.5-coder, ...)
lmstudio — Local LM Studio (any loaded model)
custom — Any OpenAI-compatible endpoint
Expand Down Expand Up @@ -99,6 +100,30 @@
"deepseek-chat", "deepseek-coder", "deepseek-reasoner",
],
},
"minimax": {
"type": "openai",
"api_key_env": "MINIMAX_API_KEY",
"base_url": "https://api.minimax.io/v1",
"anthropic_base_url": "https://api.minimax.io/anthropic",
"context_limit": 1000000,
"models": [
"MiniMax-M3", "MiniMax-M2.7",
],
"regional_endpoints": [
{
"region": "global_en",
"openai_base_url": "https://api.minimax.io/v1",
"anthropic_base_url": "https://api.minimax.io/anthropic",
"docs_root": "https://platform.minimax.io/docs",
},
{
"region": "cn_zh",
"openai_base_url": "https://api.minimaxi.com/v1",
"anthropic_base_url": "https://api.minimaxi.com/anthropic",
"docs_root": "https://platform.minimaxi.com/docs",
},
],
},
"ollama": {
"type": "ollama",
"api_key_env": None,
Expand Down Expand Up @@ -127,7 +152,35 @@
},
}

# Cost per million tokens (approximate, fallback to 0 for unknown)
# Per-model metadata for models whose capabilities or pricing do not fit the
# provider-level defaults. Prices are in USD per million tokens.
MODEL_METADATA = {
"MiniMax-M3": {
"context_limit": 1000000,
"input_modalities": ["text", "image", "video"],
"thinking": ["adaptive", "disabled"],
"pricing": {
"input": 0.6,
"output": 2.4,
"cache_read": 0.12,
"cache_write": None,
},
},
"MiniMax-M2.7": {
"context_limit": 204800,
"input_modalities": ["text"],
"thinking": ["always_on"],
"pricing": {
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": 0.375,
},
},
}

# Cost per million tokens for models with simple pricing (approximate,
# fallback to 0 for unknown models).
COSTS = {
"claude-opus-4-6": (15.0, 75.0),
"claude-sonnet-4-6": (3.0, 15.0),
Expand Down Expand Up @@ -161,6 +214,7 @@
("qwq-", "qwen"),
("glm-", "zhipu"),
("deepseek-", "deepseek"),
("minimax-", "minimax"),
("llama", "ollama"),
("mistral", "ollama"),
("phi", "ollama"),
Expand All @@ -184,6 +238,21 @@ def bare_model(model: str) -> str:
return model.split("/", 1)[1] if "/" in model else model


def get_model_metadata(model: str) -> dict:
"""Return model-specific metadata, independent of adapter prefix."""
return MODEL_METADATA.get(bare_model(model), {})


def get_context_limit(model: str) -> int:
"""Return the model context limit, falling back to its provider default."""
metadata = get_model_metadata(model)
if metadata.get("context_limit"):
return metadata["context_limit"]
provider_name = detect_provider(model)
provider = PROVIDERS.get(provider_name, {})
return provider.get("context_limit", 128000)


def get_api_key(provider_name: str, config: dict) -> str:
prov = PROVIDERS.get(provider_name, {})
# 1. Check config dict (e.g. config["kimi_api_key"])
Expand All @@ -200,6 +269,9 @@ def get_api_key(provider_name: str, config: dict) -> str:


def calc_cost(model: str, in_tok: int, out_tok: int) -> float:
rates = get_model_metadata(model).get("pricing", {})
if rates:
return (in_tok * rates["input"] + out_tok * rates["output"]) / 1_000_000
ic, oc = COSTS.get(bare_model(model), (0.0, 0.0))
return (in_tok * ic + out_tok * oc) / 1_000_000

Expand Down
8 changes: 8 additions & 0 deletions clawspring/tests/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ def test_unknown_model_fallback(self):
def test_explicit_provider_prefix(self):
assert get_context_limit("ollama/llama3.3") == 128000

def test_minimax_models_use_model_specific_limits(self):
assert get_context_limit("MiniMax-M3") == 1000000
assert get_context_limit("MiniMax-M2.7") == 204800

def test_minimax_limit_is_independent_of_adapter_prefix(self):
assert get_context_limit("custom/MiniMax-M3") == 1000000
assert get_context_limit("anthropic/MiniMax-M2.7") == 204800


# ── snip_old_tool_results ─────────────────────────────────────────────────

Expand Down
78 changes: 78 additions & 0 deletions clawspring/tests/test_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Focused tests for provider registry metadata and pricing."""
from __future__ import annotations

import os
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from providers import ( # noqa: E402
MODEL_METADATA,
PROVIDERS,
calc_cost,
detect_provider,
get_api_key,
)


def test_minimax_registry_and_detection():
provider = PROVIDERS["minimax"]
assert provider["type"] == "openai"
assert provider["base_url"] == "https://api.minimax.io/v1"
assert provider["anthropic_base_url"] == "https://api.minimax.io/anthropic"
assert provider["models"] == ["MiniMax-M3", "MiniMax-M2.7"]
assert detect_provider("MiniMax-M3") == "minimax"
assert detect_provider("MiniMax-M2.7") == "minimax"


def test_minimax_api_key_lookup(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
assert get_api_key("minimax", {}) == "test-key"
assert get_api_key("minimax", {"minimax_api_key": "config-key"}) == "config-key"


def test_minimax_model_capabilities():
assert MODEL_METADATA["MiniMax-M3"]["input_modalities"] == [
"text", "image", "video",
]
assert MODEL_METADATA["MiniMax-M3"]["thinking"] == ["adaptive", "disabled"]
assert MODEL_METADATA["MiniMax-M2.7"]["input_modalities"] == ["text"]
assert MODEL_METADATA["MiniMax-M2.7"]["thinking"] == ["always_on"]


def test_minimax_regional_endpoints():
assert PROVIDERS["minimax"]["regional_endpoints"] == [
{
"region": "global_en",
"openai_base_url": "https://api.minimax.io/v1",
"anthropic_base_url": "https://api.minimax.io/anthropic",
"docs_root": "https://platform.minimax.io/docs",
},
{
"region": "cn_zh",
"openai_base_url": "https://api.minimaxi.com/v1",
"anthropic_base_url": "https://api.minimaxi.com/anthropic",
"docs_root": "https://platform.minimaxi.com/docs",
},
]


def test_minimax_cache_pricing_metadata():
m3_pricing = MODEL_METADATA["MiniMax-M3"]["pricing"]
m27_pricing = MODEL_METADATA["MiniMax-M2.7"]["pricing"]
assert m3_pricing == {
"input": 0.6,
"output": 2.4,
"cache_read": 0.12,
"cache_write": None,
}
assert m27_pricing == {
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": 0.375,
}
assert calc_cost("MiniMax-M3", 1000000, 1000000) == pytest.approx(3.0)
assert calc_cost("MiniMax-M2.7", 1000000, 1000000) == pytest.approx(1.5)