From 6b4a8afa391bc8088ee1a2ac3a1feb1caaa684ab Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:44:10 +0800 Subject: [PATCH] fix(minimax): correct pricing tiers and modalities --- src/models/configs.py | 1 + src/providers/minimax_provider.py | 45 +++++++++++++++-- src/services/pricing.py | 84 +++++++++++++++++++++++++++---- tests/test_minimax_provider.py | 73 +++++++++++++++++++++++++++ tests/test_model_system.py | 4 ++ tests/test_pricing_status_bar.py | 31 ++++++++++++ 6 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 tests/test_minimax_provider.py diff --git a/src/models/configs.py b/src/models/configs.py index 3a9f1edb..7fd7721e 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -193,6 +193,7 @@ class ModelConfig: display_name="MiniMax M2.7", context_window=204_800, max_output_tokens=8_192, + supports_vision=False, supports_cache=True, ), "MiniMax-M3": ModelConfig( diff --git a/src/providers/minimax_provider.py b/src/providers/minimax_provider.py index 79a67a20..caf89a60 100644 --- a/src/providers/minimax_provider.py +++ b/src/providers/minimax_provider.py @@ -19,6 +19,11 @@ def __init__(self, *args, **kwargs): from .base import BaseProvider, ChatResponse, MessageInput, TextChunkCallback +def _usage_token_count(usage: Any, field: str) -> int: + value = getattr(usage, field, 0) + return int(value) if isinstance(value, (int, float)) else 0 + + class MinimaxProvider(BaseProvider): """Minimax AI provider using Anthropic-compatible API. @@ -65,7 +70,12 @@ def _prepare_messages(self, messages: list[Any]) -> list[dict[str, Any]]: from .openai_responses import strip_responses_item_blocks return strip_responses_item_blocks(prepared) - def _build_chat_response(self, response: Any) -> ChatResponse: + def _build_chat_response( + self, + response: Any, + *, + request_service_tier: str = "standard", + ) -> ChatResponse: content_text = "" tool_uses: list[dict[str, Any]] = [] @@ -83,12 +93,25 @@ def _build_chat_response(self, response: Any) -> ChatResponse: }) usage = getattr(response, "usage", None) + response_service_tier = getattr(usage, "service_tier", None) + service_tier = ( + response_service_tier + if response_service_tier in ("standard", "priority") + else request_service_tier + ) return ChatResponse( content=content_text, model=getattr(response, "model", self.model or ""), usage={ - "input_tokens": getattr(usage, "input_tokens", 0), - "output_tokens": getattr(usage, "output_tokens", 0), + "input_tokens": _usage_token_count(usage, "input_tokens"), + "output_tokens": _usage_token_count(usage, "output_tokens"), + "cache_creation_input_tokens": _usage_token_count( + usage, "cache_creation_input_tokens" + ), + "cache_read_input_tokens": _usage_token_count( + usage, "cache_read_input_tokens" + ), + "service_tier": service_tier, }, finish_reason=str(getattr(response, "stop_reason", "stop")), tool_uses=tool_uses if tool_uses else None, @@ -112,6 +135,9 @@ def chat( """ model = self._get_model(**kwargs) max_tokens = kwargs.get("max_tokens", 4096) + request_service_tier = ( + "priority" if kwargs.get("service_tier") == "priority" else "standard" + ) system = kwargs.pop("system", None) @@ -133,7 +159,10 @@ def chat( **{k: v for k, v in kwargs.items() if k not in ["model", "max_tokens", "tools"]}, ) - return self._build_chat_response(response) + return self._build_chat_response( + response, + request_service_tier=request_service_tier, + ) def chat_stream( self, @@ -197,6 +226,9 @@ def chat_stream_response( model = self._get_model(**kwargs) max_tokens = kwargs.get("max_tokens", 4096) + request_service_tier = ( + "priority" if kwargs.get("service_tier") == "priority" else "standard" + ) system = kwargs.pop("system", None) minimax_messages = self._prepare_messages(messages) @@ -235,7 +267,10 @@ def chat_stream_response( guard.raise_if_post_aborted() if final_message is not None: - return self._build_chat_response(final_message) + return self._build_chat_response( + final_message, + request_service_tier=request_service_tier, + ) return ChatResponse( content=streamed_text, diff --git a/src/services/pricing.py b/src/services/pricing.py index 607c187d..f8565242 100644 --- a/src/services/pricing.py +++ b/src/services/pricing.py @@ -78,14 +78,33 @@ "cache_creation": 0.435 / 1_000_000, "cache_read": 0.003625 / 1_000_000, } -# MiniMax standard pay-as-you-go rates in USD per million tokens. The static -# pricing table uses M3's <=512k input tier; longer requests have a higher tier. -_TIER_MINIMAX_M3 = { +# MiniMax M3 pay-as-you-go rates in USD per million tokens. Prompt size is the +# complete request input, including cache creation and cache read tokens. +_MINIMAX_M3_INPUT_TIER_LIMIT = 512_000 +_TIER_MINIMAX_M3_STANDARD = { "input": 0.30 / 1_000_000, "output": 1.20 / 1_000_000, "cache_creation": 0.30 / 1_000_000, "cache_read": 0.06 / 1_000_000, } +_TIER_MINIMAX_M3_STANDARD_LONG = { + "input": 0.60 / 1_000_000, + "output": 2.40 / 1_000_000, + "cache_creation": 0.60 / 1_000_000, + "cache_read": 0.12 / 1_000_000, +} +_TIER_MINIMAX_M3_PRIORITY = { + "input": 0.45 / 1_000_000, + "output": 1.80 / 1_000_000, + "cache_creation": 0.45 / 1_000_000, + "cache_read": 0.09 / 1_000_000, +} +_TIER_MINIMAX_M3_PRIORITY_LONG = { + "input": 0.90 / 1_000_000, + "output": 3.60 / 1_000_000, + "cache_creation": 0.90 / 1_000_000, + "cache_read": 0.18 / 1_000_000, +} _TIER_MINIMAX_M27 = { "input": 0.30 / 1_000_000, "output": 1.20 / 1_000_000, @@ -138,7 +157,7 @@ # every proxied model is priced at its upstream rate. "deepseek-v4-flash": _TIER_DEEPSEEK_FLASH, "deepseek-v4-pro": _TIER_DEEPSEEK_PRO, - "MiniMax-M3": _TIER_MINIMAX_M3, + "MiniMax-M3": _TIER_MINIMAX_M3_STANDARD, "MiniMax-M2.7": _TIER_MINIMAX_M27, # Meta Muse Spark (api.meta.ai) "muse-spark-1.1": _TIER_MUSE_SPARK, @@ -173,9 +192,41 @@ ] -def get_pricing(model: str) -> dict[str, float] | None: +def _get_exact_pricing( + model: str, + *, + input_tokens: int, + service_tier: str, +) -> dict[str, float] | None: + if model != "MiniMax-M3": + return PRICING.get(model) + + is_long_context = input_tokens > _MINIMAX_M3_INPUT_TIER_LIMIT + if service_tier == "priority": + return ( + _TIER_MINIMAX_M3_PRIORITY_LONG + if is_long_context + else _TIER_MINIMAX_M3_PRIORITY + ) + return ( + _TIER_MINIMAX_M3_STANDARD_LONG + if is_long_context + else _TIER_MINIMAX_M3_STANDARD + ) + + +def get_pricing( + model: str, + *, + input_tokens: int = 0, + service_tier: str = "standard", +) -> dict[str, float] | None: """Return per-token prices for ``model``, or ``None`` if unknown. + ``input_tokens`` is the complete prompt size for one request. MiniMax M3 + uses it with ``service_tier`` to select its standard/priority and + short/long-context rate. + Lookup order: 1. Exact match in ``PRICING``. 2. Strip a leading ``/`` segment (openrouter convention, @@ -194,11 +245,19 @@ def get_pricing(model: str) -> dict[str, float] | None: if not model: return None if model in PRICING: - return PRICING[model] + return _get_exact_pricing( + model, + input_tokens=input_tokens, + service_tier=service_tier, + ) if "/" in model: bare = model.split("/", 1)[1] if bare in PRICING: - return PRICING[bare] + return _get_exact_pricing( + bare, + input_tokens=input_tokens, + service_tier=service_tier, + ) for prefix, pricing in _FAMILY_PREFIXES: if bare.startswith(prefix): return pricing @@ -228,13 +287,18 @@ def compute_cost(model: str, usage: dict[str, Any]) -> float: from ``usage``. Missing keys default to zero so callers that only track input+output still get a sensible result. """ - pricing = get_pricing(model) - if pricing is None: - return 0.0 input_tokens = int(usage.get("input_tokens", 0) or 0) output_tokens = int(usage.get("output_tokens", 0) or 0) cache_creation = int(usage.get("cache_creation_input_tokens", 0) or 0) cache_read = int(usage.get("cache_read_input_tokens", 0) or 0) + prompt_tokens = input_tokens + cache_creation + cache_read + pricing = get_pricing( + model, + input_tokens=prompt_tokens, + service_tier=str(usage.get("service_tier") or "standard"), + ) + if pricing is None: + return 0.0 return ( input_tokens * pricing["input"] + output_tokens * pricing["output"] diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 00000000..efaa3e99 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,73 @@ +"""Request and usage handling for the MiniMax provider.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from src.providers.minimax_provider import MinimaxProvider + + +@pytest.mark.parametrize( + ("base_url", "expected_url"), + [ + ( + "https://api.minimax.io/anthropic", + "https://api.minimax.io/anthropic/v1/messages", + ), + ( + "https://api.minimaxi.com/anthropic", + "https://api.minimaxi.com/anthropic/v1/messages", + ), + ], +) +def test_priority_request_capture_and_usage( + base_url: str, + expected_url: str, +) -> None: + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + request=request, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "MiniMax-M3", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": { + "input_tokens": 600_001, + "output_tokens": 10, + "cache_creation_input_tokens": 11, + "cache_read_input_tokens": 12, + }, + }, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + provider = MinimaxProvider(api_key="test", base_url=base_url) + provider._client_kwargs["http_client"] = http_client + response = provider.chat( + [{"role": "user", "content": "hello"}], + service_tier="priority", + ) + provider.client.close() + + assert captured["url"] == expected_url + assert captured["body"]["service_tier"] == "priority" + assert response.usage == { + "input_tokens": 600_001, + "output_tokens": 10, + "cache_creation_input_tokens": 11, + "cache_read_input_tokens": 12, + "service_tier": "priority", + } diff --git a/tests/test_model_system.py b/tests/test_model_system.py index 636c2fbc..06d57767 100644 --- a/tests/test_model_system.py +++ b/tests/test_model_system.py @@ -99,6 +99,10 @@ def test_helper_functions(self): assert supports_tools("claude-sonnet-4-20250514") is True assert supports_vision("claude-sonnet-4-20250514") is True + def test_minimax_model_modalities(self): + assert supports_vision("MiniMax-M3") is True + assert supports_vision("MiniMax-M2.7") is False + class TestModelResolution: def test_resolve_alias(self): diff --git a/tests/test_pricing_status_bar.py b/tests/test_pricing_status_bar.py index 15f6f0b6..1b802de3 100644 --- a/tests/test_pricing_status_bar.py +++ b/tests/test_pricing_status_bar.py @@ -47,6 +47,23 @@ def test_exact_match_minimax_models(self) -> None: self.assertEqual(m27["cache_creation"], 0.375 / 1_000_000) self.assertEqual(m27["cache_read"], 0.06 / 1_000_000) + def test_minimax_m3_selects_all_context_and_service_tiers(self) -> None: + standard = get_pricing("MiniMax-M3", input_tokens=512_000) + standard_long = get_pricing("MiniMax-M3", input_tokens=512_001) + priority = get_pricing( + "MiniMax-M3", input_tokens=512_000, service_tier="priority" + ) + priority_long = get_pricing( + "MiniMax-M3", input_tokens=512_001, service_tier="priority" + ) + + self.assertEqual(standard["input"], 0.30 / 1_000_000) + self.assertEqual(standard_long["input"], 0.60 / 1_000_000) + self.assertEqual(priority["input"], 0.45 / 1_000_000) + self.assertEqual(priority_long["input"], 0.90 / 1_000_000) + self.assertEqual(priority_long["output"], 3.60 / 1_000_000) + self.assertEqual(priority_long["cache_read"], 0.18 / 1_000_000) + def test_family_prefix_falls_back_for_future_opus_variant(self) -> None: # A model name not in the exact table but matching the # ``claude-opus-4-7`` family prefix → 5/25 tier. @@ -118,6 +135,20 @@ def test_cache_creation_and_read_charged(self) -> None: # opus-4-7 (5/25 tier): $5 input + $6.25 cache_creation + $0.50 cache_read self.assertAlmostEqual(cost, 5.0 + 6.25 + 0.50, places=6) + def test_minimax_m3_priority_long_context_cost(self) -> None: + usage = { + "input_tokens": 200_000, + "output_tokens": 1_000_000, + "cache_creation_input_tokens": 200_000, + "cache_read_input_tokens": 200_001, + "service_tier": "priority", + } + + cost = compute_cost("MiniMax-M3", usage) + + expected = 0.18 + 3.60 + 0.18 + (200_001 * 0.18 / 1_000_000) + self.assertAlmostEqual(cost, expected, places=8) + def test_missing_keys_default_to_zero(self) -> None: # No tokens at all → free. self.assertEqual(compute_cost("claude-opus-4-7", {}), 0.0)