Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/models/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 40 additions & 5 deletions src/providers/minimax_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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]] = []

Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
84 changes: 74 additions & 10 deletions src/services/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ``<vendor>/`` segment (openrouter convention,
Expand All @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
73 changes: 73 additions & 0 deletions tests/test_minimax_provider.py
Original file line number Diff line number Diff line change
@@ -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",
}
4 changes: 4 additions & 0 deletions tests/test_model_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_pricing_status_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading