From dce113a1e16784ea39d05e48913a1c506bc0c55a Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:08:46 +0800 Subject: [PATCH] Add MiniMax provider support Register MiniMax-M3 and MiniMax-M2.7 with per-model context, capability, and tiered pricing metadata. Document global and CN endpoint configuration for both supported protocols and add focused tests. --- clawspring/README.md | 38 ++++++++++ clawspring/compaction.py | 4 +- clawspring/config.py | 1 + clawspring/providers.py | 110 +++++++++++++++++++++++++++- clawspring/tests/test_compaction.py | 8 ++ clawspring/tests/test_providers.py | 65 ++++++++++++++++ 6 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 clawspring/tests/test_providers.py diff --git a/clawspring/README.md b/clawspring/README.md index b248cd12..67104ebd 100644 --- a/clawspring/README.md +++ b/clawspring/README.md @@ -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) @@ -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) @@ -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 | --- @@ -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) @@ -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. diff --git a/clawspring/compaction.py b/clawspring/compaction.py index b92714e3..e07aef92 100644 --- a/clawspring/compaction.py +++ b/clawspring/compaction.py @@ -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 ──────────────────────────────────────── diff --git a/clawspring/config.py b/clawspring/config.py index 51a5b1a0..d5917741 100644 --- a/clawspring/config.py +++ b/clawspring/config.py @@ -34,6 +34,7 @@ # "qwen_api_key": "..." # "zhipu_api_key": "..." # "deepseek_api_key": "..." + # "minimax_api_key": "..." } diff --git a/clawspring/providers.py b/clawspring/providers.py index 9575b1c8..b2030715 100644 --- a/clawspring/providers.py +++ b/clawspring/providers.py @@ -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 @@ -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, @@ -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), @@ -161,6 +230,7 @@ ("qwq-", "qwen"), ("glm-", "zhipu"), ("deepseek-", "deepseek"), + ("minimax-", "minimax"), ("llama", "ollama"), ("mistral", "ollama"), ("phi", "ollama"), @@ -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"]) @@ -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 diff --git a/clawspring/tests/test_compaction.py b/clawspring/tests/test_compaction.py index 88d94e98..61fc3d82 100644 --- a/clawspring/tests/test_compaction.py +++ b/clawspring/tests/test_compaction.py @@ -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 ───────────────────────────────────────────────── diff --git a/clawspring/tests/test_providers.py b/clawspring/tests/test_providers.py new file mode 100644 index 00000000..62f5934b --- /dev/null +++ b/clawspring/tests/test_providers.py @@ -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)