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
110 changes: 108 additions & 2 deletions 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,15 @@
"deepseek-chat", "deepseek-coder", "deepseek-reasoner",
],
},
"minimax": {
"type": "openai",
"api_key_env": "MINIMAX_API_KEY",
"base_url": "https://api.minimax.io/v1",
"context_limit": 1000000,
"models": [
"MiniMax-M3", "MiniMax-M2.7",
],
},
"ollama": {
"type": "ollama",
"api_key_env": None,
Expand Down Expand Up @@ -127,7 +137,66 @@
},
}

# 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_tiers": [
{
"service_tier": "standard",
"input_tokens_lte": 512000,
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": None,
},
{
"service_tier": "standard",
"input_tokens_gt": 512000,
"input": 0.6,
"output": 2.4,
"cache_read": 0.12,
"cache_write": None,
},
{
"service_tier": "priority",
"input_tokens_lte": 512000,
"input": 0.45,
"output": 1.8,
"cache_read": 0.09,
"cache_write": None,
},
{
"service_tier": "priority",
"input_tokens_gt": 512000,
"input": 0.9,
"output": 3.6,
"cache_read": 0.18,
"cache_write": None,
},
],
},
"MiniMax-M2.7": {
"context_limit": 204800,
"input_modalities": ["text"],
"thinking": ["always_on"],
"pricing_tiers": [
{
"service_tier": "standard",
"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 +230,7 @@
("qwq-", "qwen"),
("glm-", "zhipu"),
("deepseek-", "deepseek"),
("minimax-", "minimax"),
("llama", "ollama"),
("mistral", "ollama"),
("phi", "ollama"),
Expand All @@ -184,6 +254,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 @@ -199,7 +284,28 @@ def get_api_key(provider_name: str, config: dict) -> str:
return prov.get("api_key", "")


def calc_cost(model: str, in_tok: int, out_tok: int) -> float:
def _get_pricing_tier(model: str, in_tok: int, service_tier: str) -> dict:
tiers = get_model_metadata(model).get("pricing_tiers", [])
for tier in tiers:
if tier["service_tier"] != service_tier:
continue
if "input_tokens_lte" in tier and in_tok > tier["input_tokens_lte"]:
continue
if "input_tokens_gt" in tier and in_tok <= tier["input_tokens_gt"]:
continue
return tier
return {}


def calc_cost(
model: str,
in_tok: int,
out_tok: int,
service_tier: str = "standard",
) -> float:
rates = _get_pricing_tier(model, in_tok, service_tier)
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
65 changes: 65 additions & 0 deletions clawspring/tests/test_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""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["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_m3_standard_pricing_tiers():
lower = calc_cost("MiniMax-M3", 512000, 1000000)
upper = calc_cost("MiniMax-M3", 512001, 1000000)
assert lower == pytest.approx(0.512 * 0.3 + 1.2)
assert upper == pytest.approx(0.512001 * 0.6 + 2.4)


def test_minimax_m3_priority_pricing_tiers():
lower = calc_cost("MiniMax-M3", 512000, 1000000, service_tier="priority")
upper = calc_cost("MiniMax-M3", 512001, 1000000, service_tier="priority")
assert lower == pytest.approx(0.512 * 0.45 + 1.8)
assert upper == pytest.approx(0.512001 * 0.9 + 3.6)


def test_minimax_cache_pricing_metadata():
m3_tiers = MODEL_METADATA["MiniMax-M3"]["pricing_tiers"]
m27_standard = MODEL_METADATA["MiniMax-M2.7"]["pricing_tiers"][0]
assert [tier["cache_read"] for tier in m3_tiers] == [0.06, 0.12, 0.09, 0.18]
assert all(tier["cache_write"] is None for tier in m3_tiers)
assert m27_standard["cache_read"] == 0.06
assert m27_standard["cache_write"] == 0.375
assert calc_cost("MiniMax-M2.7", 1000000, 1000000) == pytest.approx(1.5)