diff --git a/README.md b/README.md index 48c9682..f7794e1 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,14 @@ Give it a model and a key and it goes. It speaks the OpenAI-compatible API by de |---|---| | OpenAI (default `gpt-5.5`) | `OPENAI_API_KEY=sk-...` | | DeepSeek | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.deepseek.com CORECODER_MODEL=deepseek-chat` | +| MiniMax (global, OpenAI) | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.minimax.io/v1 CORECODER_MODEL=MiniMax-M3 CORECODER_MAX_CONTEXT=1000000` | +| MiniMax (China, OpenAI) | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.minimaxi.com/v1 CORECODER_MODEL=MiniMax-M3 CORECODER_MAX_CONTEXT=1000000` | +| MiniMax (global, Anthropic via LiteLLM) | `CORECODER_API_KEY=sk-... CORECODER_BASE_URL=https://api.minimax.io/anthropic CORECODER_MODEL=anthropic/MiniMax-M3 CORECODER_MAX_CONTEXT=1000000 CORECODER_PROVIDER=litellm` | +| MiniMax (China, Anthropic via LiteLLM) | `CORECODER_API_KEY=sk-... CORECODER_BASE_URL=https://api.minimaxi.com/anthropic CORECODER_MODEL=anthropic/MiniMax-M3 CORECODER_MAX_CONTEXT=1000000 CORECODER_PROVIDER=litellm` | | Local Ollama | `OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/v1 CORECODER_MODEL=qwen2.5-coder` | +MiniMax-M2.7 is available through the same four endpoints. Replace `MiniMax-M3` with `MiniMax-M2.7` and set `CORECODER_MAX_CONTEXT=204800`; keep the `anthropic/` prefix when using LiteLLM. The Anthropic-compatible examples require `pip install "corecoder[litellm]"`. + Kimi, Qwen and the like are the same two variables; for providers that don't even offer an OpenAI-compatible endpoint, the optional LiteLLM backend (`pip install "corecoder[litellm]"`) routes to a hundred-plus of them. The third essay goes into this in detail. The key can be `export`ed directly or dropped into a `.env` at the project root, which is loaded on startup. Then: ```bash diff --git a/README_CN.md b/README_CN.md index 328e6ab..5f33be3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -69,8 +69,14 @@ pip install -e . |---|---| | OpenAI(默认 `gpt-5.5`) | `OPENAI_API_KEY=sk-...` | | DeepSeek | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.deepseek.com CORECODER_MODEL=deepseek-chat` | +| MiniMax (global, OpenAI) | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.minimax.io/v1 CORECODER_MODEL=MiniMax-M3 CORECODER_MAX_CONTEXT=1000000` | +| MiniMax (China, OpenAI) | `OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.minimaxi.com/v1 CORECODER_MODEL=MiniMax-M3 CORECODER_MAX_CONTEXT=1000000` | +| MiniMax (global, Anthropic via LiteLLM) | `CORECODER_API_KEY=sk-... CORECODER_BASE_URL=https://api.minimax.io/anthropic CORECODER_MODEL=anthropic/MiniMax-M3 CORECODER_MAX_CONTEXT=1000000 CORECODER_PROVIDER=litellm` | +| MiniMax (China, Anthropic via LiteLLM) | `CORECODER_API_KEY=sk-... CORECODER_BASE_URL=https://api.minimaxi.com/anthropic CORECODER_MODEL=anthropic/MiniMax-M3 CORECODER_MAX_CONTEXT=1000000 CORECODER_PROVIDER=litellm` | | 本地 Ollama | `OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/v1 CORECODER_MODEL=qwen2.5-coder` | +MiniMax-M2.7 is available through the same four endpoints. Replace `MiniMax-M3` with `MiniMax-M2.7` and set `CORECODER_MAX_CONTEXT=204800`; keep the `anthropic/` prefix when using LiteLLM. The Anthropic-compatible examples require `pip install "corecoder[litellm]"`. + Kimi、Qwen 这些同样是改这两个变量;连 OpenAI 兼容接口都不给的 provider,装上可选的 LiteLLM 后端(`pip install "corecoder[litellm]"`)能路由一百多家。第三篇文章把这块讲得更细。key 可以直接 `export`,也可以在项目根目录扔个 `.env`,启动时自动加载。然后: ```bash diff --git a/corecoder/llm.py b/corecoder/llm.py index 1870830..922552b 100644 --- a/corecoder/llm.py +++ b/corecoder/llm.py @@ -49,9 +49,9 @@ def message(self) -> dict: return msg -# pricing per million tokens: (input, output) +# pricing per million tokens: (input, output, optional cache read, optional cache write) # sources: openai.com/api/pricing, api-docs.deepseek.com, platform.claude.com, -# platform.moonshot.ai, alibabacloud.com/help/en/model-studio +# platform.moonshot.ai, alibabacloud.com/help/en/model-studio, platform.minimax.io _PRICING = { # OpenAI - current flagships "gpt-5.5": (5, 30), @@ -78,9 +78,55 @@ def message(self) -> dict: "qwen-max": (0.78, 3.9), # Moonshot Kimi "kimi-k2.5": (0.6, 3), + # MiniMax + "MiniMax-M2.7": (0.3, 1.2, 0.06, 0.375), } +# tier entries: (maximum input tokens, input, output, cache read, cache write) +_TIERED_PRICING = { + "MiniMax-M3": { + "standard": ( + (512_000, 0.3, 1.2, 0.06, None), + (None, 0.6, 2.4, 0.12, None), + ), + "priority": ( + (512_000, 0.45, 1.8, 0.09, None), + (None, 0.9, 3.6, 0.18, None), + ), + }, +} + + +def _pricing_key(model: str) -> str: + """Strip a LiteLLM provider prefix before looking up model pricing.""" + return model.rsplit("/", 1)[-1] + + +def _cost_for_usage( + model: str, + prompt_tokens: int, + completion_tokens: int, + service_tier: str = "standard", +) -> float | None: + key = _pricing_key(model) + pricing = _PRICING.get(key) + if pricing: + input_rate, output_rate = pricing[:2] + else: + model_tiers = _TIERED_PRICING.get(key) + if not model_tiers: + return None + tiers = model_tiers.get(service_tier, model_tiers["standard"]) + _, input_rate, output_rate, _, _ = next( + tier for tier in tiers if tier[0] is None or prompt_tokens <= tier[0] + ) + return ( + prompt_tokens * input_rate / 1_000_000 + + completion_tokens * output_rate / 1_000_000 + ) + + class LLM: def __init__( self, @@ -94,19 +140,34 @@ def __init__( self.extra = kwargs # temperature, max_tokens, etc. self.total_prompt_tokens = 0 self.total_completion_tokens = 0 + self._estimated_cost = _cost_for_usage( + model, 0, 0, kwargs.get("service_tier", "standard") + ) @property def estimated_cost(self) -> float | None: """Rough cost estimate in USD. Returns None if model not in pricing table.""" - pricing = _PRICING.get(self.model) - if not pricing: - return None - input_rate, output_rate = pricing - return ( - self.total_prompt_tokens * input_rate / 1_000_000 - + self.total_completion_tokens * output_rate / 1_000_000 + if "_estimated_cost" in self.__dict__: + return self._estimated_cost + return _cost_for_usage( + self.model, + self.total_prompt_tokens, + self.total_completion_tokens, + getattr(self, "extra", {}).get("service_tier", "standard"), ) + def _record_usage(self, prompt_tokens: int, completion_tokens: int): + self.total_prompt_tokens += prompt_tokens + self.total_completion_tokens += completion_tokens + request_cost = _cost_for_usage( + self.model, + prompt_tokens, + completion_tokens, + self.extra.get("service_tier", "standard"), + ) + if request_cost is not None: + self._estimated_cost = (self._estimated_cost or 0.0) + request_cost + def chat( self, messages: list[dict], @@ -180,8 +241,7 @@ def chat( args = {} parsed.append(ToolCall(id=raw["id"], name=raw["name"], arguments=args)) - self.total_prompt_tokens += prompt_tok - self.total_completion_tokens += completion_tok + self._record_usage(prompt_tok, completion_tok) return LLMResponse( content="".join(content_parts), @@ -236,6 +296,9 @@ def __init__( self.extra = kwargs self.total_prompt_tokens = 0 self.total_completion_tokens = 0 + self._estimated_cost = _cost_for_usage( + model, 0, 0, kwargs.get("service_tier", "standard") + ) def chat( self, @@ -300,8 +363,7 @@ def chat( args = {} parsed.append(ToolCall(id=raw["id"], name=raw["name"], arguments=args)) - self.total_prompt_tokens += prompt_tok - self.total_completion_tokens += completion_tok + self._record_usage(prompt_tok, completion_tok) return LLMResponse( content="".join(content_parts), diff --git a/tests/test_core.py b/tests/test_core.py index fd86c66..c42afce 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,7 @@ """Tests for core modules: config, context, session, imports.""" +import pytest + from corecoder import Agent, LLM, Config, ALL_TOOLS, __version__ from corecoder import session as session_module from corecoder.context import ContextManager, estimate_tokens @@ -150,6 +152,61 @@ def test_cost_estimation_unknown_model(): assert llm.estimated_cost is None +def test_minimax_pricing_registry_preserves_official_tiers(): + from corecoder.llm import _PRICING, _TIERED_PRICING + + assert _PRICING["MiniMax-M2.7"] == (0.3, 1.2, 0.06, 0.375) + assert _TIERED_PRICING["MiniMax-M3"] == { + "standard": ( + (512_000, 0.3, 1.2, 0.06, None), + (None, 0.6, 2.4, 0.12, None), + ), + "priority": ( + (512_000, 0.45, 1.8, 0.09, None), + (None, 0.9, 3.6, 0.18, None), + ), + } + + +@pytest.mark.parametrize( + ("model", "prompt_tokens", "service_tier", "expected"), + [ + ("MiniMax-M3", 500_000, "standard", 0.27), + ("MiniMax-M3", 600_000, "standard", 0.60), + ("MiniMax-M3", 500_000, "priority", 0.405), + ("anthropic/MiniMax-M3", 600_000, "priority", 0.90), + ], +) +def test_minimax_m3_cost_estimation_uses_request_tier( + model, prompt_tokens, service_tier, expected +): + from corecoder.llm import LLM + + llm = LLM.__new__(LLM) + llm.model = model + llm.total_prompt_tokens = prompt_tokens + llm.total_completion_tokens = 100_000 + llm.extra = {"service_tier": service_tier} + assert llm.estimated_cost == pytest.approx(expected) + + +def test_minimax_m3_cost_tracks_each_request_separately(): + from corecoder.llm import LLM + + llm = LLM.__new__(LLM) + llm.model = "MiniMax-M3" + llm.extra = {} + llm.total_prompt_tokens = 0 + llm.total_completion_tokens = 0 + llm._estimated_cost = 0.0 + + llm._record_usage(400_000, 100_000) + llm._record_usage(400_000, 100_000) + + assert llm.total_prompt_tokens == 800_000 + assert llm.estimated_cost == pytest.approx(0.48) + + # --- Changed files tracking --- def test_edit_tracks_changed_files(tmp_path):