Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions intentkit/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions intentkit/models/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ 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
price_level: int | None = Field(
default=None, ge=1, le=5
Expand All @@ -244,6 +245,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
)
Expand Down Expand Up @@ -331,7 +334,10 @@ async def get_all(cls) -> list["LLMModelInfo"]:
return list(AVAILABLE_MODELS.values())

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,
) -> Decimal:
"""Calculate the cost for a given number of tokens."""
global credit_per_usdc
Expand Down Expand Up @@ -372,7 +378,10 @@ async def calculate_cost(
)

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,
) -> Decimal:
"""USD cost for a generation's token usage.

Expand Down Expand Up @@ -467,7 +476,10 @@ 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,
) -> Decimal:
"""Calculate the cost for a given number of tokens."""
info = await self.model_info()
Expand Down Expand Up @@ -888,7 +900,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,
}
Expand Down
38 changes: 34 additions & 4 deletions intentkit/models/llm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# {"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.


Expand Down Expand Up @@ -110,7 +112,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
Expand Down Expand Up @@ -887,9 +889,10 @@
enabled: true
input_price: "0.6"
cached_input_price: "0.12"
cache_write_price: null
output_price: "2.4"
price_level: 2
context_length: 524288
context_length: 1000000
output_length: 512000
intelligence: 5
speed: 3
Expand All @@ -898,7 +901,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
2 changes: 1 addition & 1 deletion tests/models/test_llm_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
219 changes: 219 additions & 0 deletions tests/models/test_minimax_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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.input_price == Decimal("0.6")
assert m3.cached_input_price == Decimal("0.12")
assert m3.cache_write_price is None
assert m3.output_price == Decimal("2.4")
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_uses_target_pricing_for_costs():
m3 = _load_minimax_catalog()["minimax:MiniMax-M3"]

assert m3.cost_usd(600_000, 1_000_000) == Decimal("2.76")
assert m3.cost_usd(
600_000, 1_000_000, cached_input_tokens=100_000
) == Decimal("2.712")


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"