From ead845d052e6a9acabac34bdc9269cb7d24ee4db Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:56:49 +0800 Subject: [PATCH] feat: refresh MiniMax model registry --- .env.example | 8 + intentkit/config/config.py | 3 + intentkit/models/llm.py | 93 +++++++--- intentkit/models/llm.yaml | 70 +++++++- tests/models/test_llm_cost.py | 2 +- tests/models/test_minimax_provider.py | 237 ++++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 29 deletions(-) create mode 100644 tests/models/test_minimax_provider.py diff --git a/.env.example b/.env.example index 62d11dc5..b8cd8cbd 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,17 @@ GOOGLE_API_KEY= GOOGLE_GENAI_USE_VERTEXAI=false #GOOGLE_CLOUD_PROJECT= MINIMAX_PLAN_API_KEY= +# Use https://api.minimaxi.com/anthropic in China. +MINIMAX_PLAN_BASE_URL=https://api.minimax.io/anthropic MIMO_PLAN_API_KEY= # OpenAI Compatible Provider +# MiniMax example: use https://api.minimax.io/v1 globally or +# https://api.minimaxi.com/v1 in China, with MiniMax-M3 or MiniMax-M2.7. +# OPENAI_COMPATIBLE_PROVIDER=MiniMax +# OPENAI_COMPATIBLE_BASE_URL=https://api.minimax.io/v1 +# OPENAI_COMPATIBLE_MODEL=MiniMax-M3 +# OPENAI_COMPATIBLE_MODEL_LITE=MiniMax-M2.7 OPENAI_COMPATIBLE_API_KEY= OPENAI_COMPATIBLE_PROVIDER= OPENAI_COMPATIBLE_BASE_URL= diff --git a/intentkit/config/config.py b/intentkit/config/config.py index efe6479f..9830a2b9 100644 --- a/intentkit/config/config.py +++ b/intentkit/config/config.py @@ -156,6 +156,9 @@ def __init__(self) -> None: self.deepseek_api_key: str | None = self.load("DEEPSEEK_API_KEY") self.xai_api_key: str | None = self.load("XAI_API_KEY") self.minimax_plan_api_key: str | None = self.load("MINIMAX_PLAN_API_KEY") + self.minimax_plan_base_url: str = self.load( + "MINIMAX_PLAN_BASE_URL", "https://api.minimax.io/anthropic" + ) self.mimo_plan_api_key: str | None = self.load("MIMO_PLAN_API_KEY") self.openrouter_api_key: str | None = self.load("OPENROUTER_API_KEY") # OpenAI Compatible provider diff --git a/intentkit/models/llm.py b/intentkit/models/llm.py index b95f0b07..d7c44c1a 100644 --- a/intentkit/models/llm.py +++ b/intentkit/models/llm.py @@ -213,6 +213,26 @@ def display_name(self) -> str: return display_names.get(self, self.value) +class LLMPricingTier(BaseModel): + """Token pricing for one service tier and input-length range.""" + + service_tier: str = "standard" + input_tokens_lte: int | None = Field(default=None, ge=0) + input_tokens_gt: int | None = Field(default=None, ge=0) + input_price: Decimal + cached_input_price: Decimal | None = None + cache_write_price: Decimal | None = None + output_price: Decimal + + def matches(self, input_tokens: int, service_tier: str) -> bool: + """Return whether this tier applies to the request.""" + if self.service_tier != service_tier: + return False + if self.input_tokens_lte is not None and input_tokens > self.input_tokens_lte: + return False + return self.input_tokens_gt is None or input_tokens > self.input_tokens_gt + + class LLMModelInfo(BaseModel): """Information about an LLM model.""" @@ -229,7 +249,9 @@ class LLMModelInfo(BaseModel): enabled: bool = Field(default=True) input_price: Decimal # Price per 1M input tokens in USD cached_input_price: Decimal | None = None # Price per 1M cached input tokens in USD + cache_write_price: Decimal | None = None # Price per 1M cache-write tokens in USD output_price: Decimal # Price per 1M output tokens in USD + pricing_tiers: list[LLMPricingTier] = Field(default_factory=list) price_level: int | None = Field( default=None, ge=1, le=5 ) # Price level rating from 1-5 @@ -244,6 +266,8 @@ class LLMModelInfo(BaseModel): reasoning_effort: str | None = ( None # Reasoning effort level: "xhigh", "high", "medium", "low", "minimal", "none", or None ) + thinking_modes: list[str] = Field(default_factory=list) + default_thinking_mode: str | None = None supports_temperature: bool = ( True # Whether the model supports temperature parameter ) @@ -330,19 +354,43 @@ async def get_all(cls) -> list["LLMModelInfo"]: """ return list(AVAILABLE_MODELS.values()) + def prices_for( + self, input_tokens: int, service_tier: str = "standard" + ) -> tuple[Decimal, Decimal | None, Decimal | None, Decimal]: + """Return prices for the matching service tier and input range.""" + for tier in self.pricing_tiers: + if tier.matches(input_tokens, service_tier): + return ( + tier.input_price, + tier.cached_input_price, + tier.cache_write_price, + tier.output_price, + ) + return ( + self.input_price, + self.cached_input_price, + self.cache_write_price, + self.output_price, + ) + async def calculate_cost( - self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0 + self, + input_tokens: int, + output_tokens: int, + cached_input_tokens: int = 0, + service_tier: str = "standard", ) -> Decimal: """Calculate the cost for a given number of tokens.""" global credit_per_usdc if not credit_per_usdc: credit_per_usdc = (await AppSetting.payment()).credit_per_usdc + input_price, cached_input_price, _, output_price = self.prices_for( + input_tokens, service_tier + ) # Determine effective price for cached input tokens effective_cached_price = ( - self.cached_input_price - if self.cached_input_price is not None - else self.input_price + cached_input_price if cached_input_price is not None else input_price ) # Clamp cached to total input (defensive against provider inconsistencies) effective_cached = min(cached_input_tokens, input_tokens) @@ -350,10 +398,7 @@ async def calculate_cost( non_cached_input = input_tokens - effective_cached input_cost = ( - credit_per_usdc - * Decimal(non_cached_input) - * self.input_price - / Decimal(1000000) + credit_per_usdc * Decimal(non_cached_input) * input_price / Decimal(1000000) ).quantize(FOURPLACES, rounding=ROUND_HALF_UP) cached_input_cost = ( credit_per_usdc @@ -362,17 +407,18 @@ async def calculate_cost( / Decimal(1000000) ).quantize(FOURPLACES, rounding=ROUND_HALF_UP) output_cost = ( - credit_per_usdc - * Decimal(output_tokens) - * self.output_price - / Decimal(1000000) + credit_per_usdc * Decimal(output_tokens) * output_price / Decimal(1000000) ).quantize(FOURPLACES, rounding=ROUND_HALF_UP) return (input_cost + cached_input_cost + output_cost).quantize( FOURPLACES, rounding=ROUND_HALF_UP ) def cost_usd( - self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0 + self, + input_tokens: int, + output_tokens: int, + cached_input_tokens: int = 0, + service_tier: str = "standard", ) -> Decimal: """USD cost for a generation's token usage. @@ -384,15 +430,16 @@ def cost_usd( output_tokens = max(output_tokens, 0) cached = min(max(cached_input_tokens, 0), input_tokens) non_cached = input_tokens - cached + input_price, cached_input_price, _, output_price = self.prices_for( + input_tokens, service_tier + ) cached_price = ( - self.cached_input_price - if self.cached_input_price is not None - else self.input_price + cached_input_price if cached_input_price is not None else input_price ) return ( - Decimal(non_cached) * self.input_price + Decimal(non_cached) * input_price + Decimal(cached) * cached_price - + Decimal(output_tokens) * self.output_price + + Decimal(output_tokens) * output_price ) / Decimal(1000000) @@ -467,12 +514,16 @@ async def get_token_limit(self) -> int: return info.context_length async def calculate_cost( - self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0 + self, + input_tokens: int, + output_tokens: int, + cached_input_tokens: int = 0, + service_tier: str = "standard", ) -> Decimal: """Calculate the cost for a given number of tokens.""" info = await self.model_info() return await info.calculate_cost( - input_tokens, output_tokens, cached_input_tokens + input_tokens, output_tokens, cached_input_tokens, service_tier ) @@ -888,7 +939,7 @@ async def create_instance(self, params: dict[str, Any] = {}) -> BaseChatModel: kwargs: dict[str, Any] = { "model": info.id, "api_key": config.minimax_plan_api_key, - "base_url": "https://api.minimax.io/anthropic", + "base_url": config.minimax_plan_base_url, "timeout": info.timeout, "max_retries": 3, } diff --git a/intentkit/models/llm.yaml b/intentkit/models/llm.yaml index cbedb600..a3c2350d 100644 --- a/intentkit/models/llm.yaml +++ b/intentkit/models/llm.yaml @@ -5,11 +5,14 @@ # # Field notes: # prices USD per 1M tokens; quoted as strings to keep exact Decimal precision. +# pricing_tiers optional service-tier and input-length-specific prices. # provider which integration serves the model (see LLMProvider). # origin_provider OpenRouter-only. Pins the upstream provider on OpenRouter via # {"order": [origin_provider], "allow_fallbacks": false}. # Omit to let OpenRouter pick the upstream provider. # reasoning_effort "xhigh"/"high"/"medium"/"low"/"minimal"/"none" or null. +# thinking_modes provider-supported thinking controls; default_thinking_mode is +# the default used by this catalog entry's adapter. # intelligence/speed/price_level 1-5 ratings. @@ -110,7 +113,7 @@ cached_input_price: "0.12" output_price: "2.4" price_level: 2 - context_length: 524288 + context_length: 1000000 output_length: 512000 intelligence: 5 speed: 3 @@ -885,11 +888,37 @@ name: "MiniMax M3" provider: minimax enabled: true - input_price: "0.6" - cached_input_price: "0.12" - output_price: "2.4" + input_price: "0.3" + cached_input_price: "0.06" + cache_write_price: null + output_price: "1.2" + pricing_tiers: + - service_tier: standard + input_tokens_lte: 512000 + input_price: "0.3" + cached_input_price: "0.06" + cache_write_price: null + output_price: "1.2" + - service_tier: standard + input_tokens_gt: 512000 + input_price: "0.6" + cached_input_price: "0.12" + cache_write_price: null + output_price: "2.4" + - service_tier: priority + input_tokens_lte: 512000 + input_price: "0.45" + cached_input_price: "0.09" + cache_write_price: null + output_price: "1.8" + - service_tier: priority + input_tokens_gt: 512000 + input_price: "0.9" + cached_input_price: "0.18" + cache_write_price: null + output_price: "3.6" price_level: 2 - context_length: 524288 + context_length: 1000000 output_length: 512000 intelligence: 5 speed: 3 @@ -898,7 +927,34 @@ supports_video_input: true supports_file_input: false reasoning_effort: "high" + thinking_modes: ["adaptive", "disabled"] + default_thinking_mode: "disabled" supports_temperature: true - supports_frequency_penalty: true - supports_presence_penalty: true + supports_frequency_penalty: false + supports_presence_penalty: false + timeout: 300 + +- id: "MiniMax-M2.7" + name: "MiniMax M2.7" + provider: minimax + enabled: true + input_price: "0.3" + cached_input_price: "0.06" + cache_write_price: "0.375" + output_price: "1.2" + price_level: 2 + context_length: 204800 + output_length: 204800 + intelligence: 5 + speed: 3 + supports_image_input: false + supports_audio_input: false + supports_video_input: false + supports_file_input: false + reasoning_effort: "high" + thinking_modes: ["always_on"] + default_thinking_mode: "always_on" + supports_temperature: true + supports_frequency_penalty: false + supports_presence_penalty: false timeout: 300 diff --git a/tests/models/test_llm_cost.py b/tests/models/test_llm_cost.py index 4c98d94f..09d145f8 100644 --- a/tests/models/test_llm_cost.py +++ b/tests/models/test_llm_cost.py @@ -7,7 +7,7 @@ from intentkit.models.llm import LLMModelInfo, _resolve_generation_cost -def _info(input_price="0.5", cached="0.05", output="3"): +def _info(input_price: str = "0.5", cached: str | None = "0.05", output: str = "3"): return LLMModelInfo.model_construct( input_price=Decimal(input_price), cached_input_price=Decimal(cached) if cached is not None else None, diff --git a/tests/models/test_minimax_provider.py b/tests/models/test_minimax_provider.py new file mode 100644 index 00000000..db4039fb --- /dev/null +++ b/tests/models/test_minimax_provider.py @@ -0,0 +1,237 @@ +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import httpx +import pytest +from langchain_anthropic import ChatAnthropic +from langchain_openai import ChatOpenAI +from pydantic import SecretStr + +from intentkit.config.config import Config +from intentkit.models.llm import ( + LLMModelInfo, + MiniMaxLLM, + load_default_llm_models, +) + + +def _load_minimax_catalog() -> dict[str, LLMModelInfo]: + configured = SimpleNamespace( + openai_api_key=None, + google_api_key=None, + deepseek_api_key=None, + xai_api_key=None, + openrouter_api_key=None, + minimax_plan_api_key="test-key", + mimo_plan_api_key=None, + openai_compatible_api_key=None, + openai_compatible_base_url=None, + openai_compatible_model=None, + openai_compatible_model_lite=None, + anthropic_compatible_api_key=None, + anthropic_compatible_base_url=None, + anthropic_compatible_model=None, + anthropic_compatible_model_lite=None, + ) + with patch("intentkit.models.llm.config", configured): + return load_default_llm_models() + + +def test_minimax_catalog_covers_current_models_and_capabilities(): + models = _load_minimax_catalog() + + m3 = models["minimax:MiniMax-M3"] + assert m3.context_length == 1_000_000 + assert m3.output_length == 512_000 + assert m3.supports_image_input is True + assert m3.supports_video_input is True + assert m3.thinking_modes == ["adaptive", "disabled"] + assert m3.default_thinking_mode == "disabled" + + m27 = models["minimax:MiniMax-M2.7"] + assert m27.context_length == 204_800 + assert m27.output_length == 204_800 + assert m27.supports_image_input is False + assert m27.supports_video_input is False + assert m27.thinking_modes == ["always_on"] + assert m27.default_thinking_mode == "always_on" + assert m27.cache_write_price == Decimal("0.375") + + +def test_minimax_m3_preserves_input_length_and_service_tier_pricing(): + m3 = _load_minimax_catalog()["minimax:MiniMax-M3"] + + assert m3.prices_for(512_000) == ( + Decimal("0.3"), + Decimal("0.06"), + None, + Decimal("1.2"), + ) + assert m3.prices_for(512_001) == ( + Decimal("0.6"), + Decimal("0.12"), + None, + Decimal("2.4"), + ) + assert m3.prices_for(512_000, "priority") == ( + Decimal("0.45"), + Decimal("0.09"), + None, + Decimal("1.8"), + ) + assert m3.prices_for(512_001, "priority") == ( + Decimal("0.9"), + Decimal("0.18"), + None, + Decimal("3.6"), + ) + assert m3.cost_usd(600_000, 1_000_000) == Decimal("2.76") + assert m3.cost_usd(600_000, 1_000_000, service_tier="priority") == Decimal("4.14") + + +def test_minimax_endpoint_examples_cover_both_regions_and_protocols(): + env_example = Path(".env.example").read_text(encoding="utf-8") + + assert "https://api.minimax.io/anthropic" in env_example + assert "https://api.minimaxi.com/anthropic" in env_example + assert "https://api.minimax.io/v1" in env_example + assert "https://api.minimaxi.com/v1" in env_example + + +def test_minimax_base_url_defaults_to_global_anthropic_endpoint(monkeypatch): + monkeypatch.setenv("REDIS_HOST", "localhost") + monkeypatch.delenv("MINIMAX_PLAN_BASE_URL", raising=False) + monkeypatch.setattr(Config, "_setup_langfuse", lambda self: None) + + cfg = Config() + + assert cfg.minimax_plan_base_url == "https://api.minimax.io/anthropic" + + +@pytest.mark.asyncio +async def test_minimax_adapter_uses_configured_anthropic_base_url(monkeypatch): + m3 = _load_minimax_catalog()["minimax:MiniMax-M3"] + captured: dict[str, object] = {} + + class FakeChatAnthropic: + def __init__(self, **kwargs): + captured.update(kwargs) + + async def fake_get(model_id: str) -> LLMModelInfo: + assert model_id == m3.id + return m3 + + monkeypatch.setattr("langchain_anthropic.ChatAnthropic", FakeChatAnthropic) + monkeypatch.setattr(LLMModelInfo, "get", staticmethod(fake_get)) + monkeypatch.setattr("intentkit.models.llm.config.minimax_plan_api_key", "test-key") + monkeypatch.setattr( + "intentkit.models.llm.config.minimax_plan_base_url", + "https://api.minimaxi.com/anthropic", + ) + + model = MiniMaxLLM(model_name=m3.id, info=m3) + await model.create_instance() + + assert captured["base_url"] == "https://api.minimaxi.com/anthropic" + assert captured["model"] == "MiniMax-M3" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "base_url", + ["https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"], +) +async def test_anthropic_client_appends_messages_path(base_url): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + 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": 1, "output_tokens": 1}, + }, + ) + + transport = httpx.MockTransport(handler) + async_client = httpx.AsyncClient(transport=transport) + with patch( + "langchain_anthropic.chat_models._get_default_async_httpx_client", + return_value=async_client, + ): + model = ChatAnthropic.model_validate( + { + "model": "MiniMax-M3", + "api_key": "test-key", + "base_url": base_url, + "max_retries": 0, + } + ) + + try: + await model.ainvoke("hello") + finally: + await async_client.aclose() + + assert requests[-1].url.path == "/anthropic/v1/messages" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "base_url", ["https://api.minimax.io/v1", "https://api.minimaxi.com/v1"] +) +async def test_openai_client_appends_chat_completions_path(base_url): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "id": "chatcmpl_test", + "object": "chat.completion", + "created": 0, + "model": "MiniMax-M3", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + }, + ) + + transport = httpx.MockTransport(handler) + async_client = httpx.AsyncClient(transport=transport) + sync_client = httpx.Client(transport=transport) + model = ChatOpenAI( + model="MiniMax-M3", + api_key=SecretStr("test-key"), + base_url=base_url, + http_async_client=async_client, + http_client=sync_client, + max_retries=0, + ) + + try: + await model.ainvoke("hello") + finally: + await async_client.aclose() + sync_client.close() + + assert requests[-1].url.path == "/v1/chat/completions"