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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ XAI_API_KEY=
GOOGLE_API_KEY=
GOOGLE_GENAI_USE_VERTEXAI=false
#GOOGLE_CLOUD_PROJECT=
# Direct API: new text requests use MiniMax-M3.1-Flash; saved M3 requests stay M3.
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# Existing deployments may keep these fallback settings. Account/model access
# depends on the configured endpoint and plan; no domestic plan entitlement is implied.
MINIMAX_PLAN_API_KEY=
#MINIMAX_PLAN_BASE_URL=
MIMO_PLAN_API_KEY=

# OpenAI Compatible Provider
Expand Down
8 changes: 7 additions & 1 deletion intentkit/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,13 @@ def __init__(self) -> None:
self.google_cloud_project: str | None = self.load("GOOGLE_CLOUD_PROJECT")
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_base_url: str = self.load(
"MINIMAX_BASE_URL",
self.load("MINIMAX_PLAN_BASE_URL", "https://api.minimax.io/anthropic"),
)
self.minimax_plan_api_key: str | None = self.load(
"MINIMAX_API_KEY"
) or self.load("MINIMAX_PLAN_API_KEY")
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
49 changes: 35 additions & 14 deletions intentkit/models/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ class LLMModelInfo(BaseModel):
),
)
enabled: bool = Field(default=True)
selectable: bool = Field(
default=True,
description="Offer in model pickers; false retains explicit saved model requests.",
)
input_price: Decimal # Price per 1M input tokens in USD
cached_input_price: Decimal | None = None # Price per 1M cached input tokens in USD
output_price: Decimal # Price per 1M output tokens in USD
Expand Down Expand Up @@ -424,7 +428,7 @@ async def get_all(cls) -> list["LLMModelInfo"]:

Kept ``async`` (despite no I/O) so the awaited call sites stay stable.
"""
return list(AVAILABLE_MODELS.values())
return [model for model in AVAILABLE_MODELS.values() if model.selectable]

async def calculate_cost(
self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0
Expand Down Expand Up @@ -846,9 +850,20 @@ async def create_instance(self) -> BaseChatModel:
"app_categories": ["cloud-agent"],
}

# Agent efforts use OpenRouter's own enum, so the clamped value goes on
# the wire as-is. "none" is deliberately forwarded: OpenRouter uses it
# to disable reasoning on hybrid upstream models.
# Flash always thinks; reject unsupported explicit values rather than
# silently turning a request for disabled thinking into a thinking run.
if (
info.api_id == "minimax/minimax-m3.1-flash:free"
and self.reasoning_effort
in {
"none",
"minimal",
}
):
raise ValueError(
"MiniMax-M3.1-Flash requires low/medium/high/xhigh/max effort"
)
# Other models retain OpenRouter's normal effort clamping.
effort = info.resolve_reasoning_effort(self.reasoning_effort)
if effort is not None:
kwargs["reasoning"] = {"effort": effort}
Expand Down Expand Up @@ -929,20 +944,26 @@ async def create_instance(self) -> BaseChatModel:
kwargs: dict[str, Any] = {
"model": info.api_id,
"api_key": config.minimax_plan_api_key,
"base_url": "https://api.minimax.io/anthropic",
"base_url": config.minimax_base_url,
"timeout": info.timeout,
"max_retries": 3,
}

# MiniMax's Anthropic-compatible endpoint defaults to thinking
# DISABLED (unlike its OpenAI-compatible one), so always send the
# toggle. It only understands "adaptive" and "disabled". Note the
# generic AnthropicCompatibleLLM below deliberately sends no thinking
# config: arbitrary endpoints disagree on the accepted shape.
effort = info.resolve_reasoning_effort(self.reasoning_effort)
kwargs["thinking"] = {
"type": "adaptive" if effort and effort != "none" else "disabled"
}
if info.api_id == "MiniMax-M3.1-Flash":
effort = self.reasoning_effort or info.reasoning_effort or "max"
if effort not in {"low", "medium", "high", "xhigh", "max"}:
raise ValueError(
"MiniMax-M3.1-Flash requires low/medium/high/xhigh/max effort"
)
kwargs["thinking"] = {"type": "adaptive"}
kwargs["output_config"] = {"effort": effort}
elif info.api_id != "MiniMax-M2.7":
# M2.7 keeps its always-on default. M3 ignores effort depth;
# keep the saved M3 agent's thinking toggle.
effort = info.resolve_reasoning_effort(self.reasoning_effort)
kwargs["thinking"] = {
"type": "adaptive" if effort and effort != "none" else "disabled"
}

return ChatAnthropic(**kwargs)

Expand Down
81 changes: 80 additions & 1 deletion intentkit/models/llm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,33 @@
reasoning_levels: ["none", "high"]
timeout: 300

# Gateway metadata follows OpenRouter's Minimax endpoint, not direct API prices.
# https://openrouter.ai/api/v1/models/minimax/minimax-m3.1-flash:free/endpoints
- id: "minimax/minimax-flash:free"
api_id: "minimax/minimax-m3.1-flash:free"
name: "MiniMax M3.1 Flash (free)"
provider: openrouter
origin_provider: "minimax"
enabled: true
input_price: "0"
output_price: "0"
price_level: 1
context_length: 1000000
output_length: 524288
intelligence: 5
speed: 4
supports_image_input: true
supports_audio_input: false
supports_video_input: true
reasoning_effort: "max"
reasoning_levels: ["low", "medium", "high", "xhigh", "max"]
timeout: 300

- id: "minimax/minimax"
api_id: "minimax/minimax-m3"
name: "MiniMax M3"
provider: openrouter
origin_provider: "minimax/fp8" # lock OpenRouter upstream
legacy_ids: ["minimax/minimax-m2-her"]
enabled: true
input_price: "0.3"
cached_input_price: "0.06"
Expand Down Expand Up @@ -848,10 +869,68 @@
reasoning_levels: ["low", "medium", "high", "xhigh"]
timeout: 180

# https://platform.minimax.io/docs/api-reference/text-anthropic-api
# https://platform.minimax.io/docs/pricing/overview
- id: "minimax-flash"
api_id: "MiniMax-M3.1-Flash"
name: "MiniMax M3.1 Flash"
provider: minimax
enabled: true
input_price: "0.15"
cached_input_price: "0.015"
output_price: "0.60"
price_level: 1
context_length: 1000000
output_length: 524288
intelligence: 5
speed: 4
supports_image_input: true
supports_audio_input: false
supports_video_input: true
reasoning_effort: "max"
reasoning_levels: ["low", "medium", "high", "xhigh", "max"]
timeout: 300

# Explicit legacy requests remain routable but are not ordinary picker choices.
- id: "MiniMax-M2.7"
api_id: "MiniMax-M2.7"
name: "MiniMax M2.7 (legacy)"
provider: minimax
selectable: false
input_price: "0.3"
cached_input_price: "0.06"
output_price: "1.2"
context_length: 204800
output_length: 204800
intelligence: 4
speed: 4
supports_image_input: false
supports_audio_input: false
supports_video_input: false
timeout: 300

- id: "minimax/minimax-m2-her"
api_id: "minimax/minimax-m2-her"
name: "MiniMax M2-her (legacy)"
provider: openrouter
selectable: false
input_price: "0.3"
cached_input_price: "0.03"
output_price: "1.2"
context_length: 65536
output_length: 2048
intelligence: 4
speed: 4
supports_image_input: false
supports_audio_input: false
supports_video_input: false
timeout: 300

- id: "minimax"
api_id: "MiniMax-M3"
name: "MiniMax M3"
provider: minimax
legacy_ids: ["minimax:MiniMax-M3"]
enabled: true
input_price: "0.3"
cached_input_price: "0.06"
Expand Down
38 changes: 23 additions & 15 deletions intentkit/models/llm_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def pick_summarize_model() -> str:
("gpt-luna", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("deepseek-flash", LLMProvider.DEEPSEEK),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, lite_compatible=True)
Expand All @@ -82,8 +82,8 @@ def pick_default_model() -> str:
"""
order: list[tuple[str, LLMProvider]] = [
("gemini-flash", LLMProvider.GOOGLE),
("minimax", LLMProvider.MINIMAX),
("minimax/minimax", LLMProvider.OPENROUTER),
("minimax-flash", LLMProvider.MINIMAX),
("minimax/minimax-flash:free", LLMProvider.OPENROUTER),
("gpt-luna", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("deepseek-flash", LLMProvider.DEEPSEEK),
Expand All @@ -105,7 +105,7 @@ def pick_lead_model() -> str:
("gemini-flash", LLMProvider.GOOGLE),
("gpt-luna", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, fallback=_DEFAULT_FALLBACK_MODEL)
Expand All @@ -120,7 +120,7 @@ def pick_lite_model() -> str:
# Luna is OpenAI's cheapest tier; glm/deepseek above are still cheaper.
("gpt-luna", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(
Expand All @@ -136,7 +136,7 @@ def pick_smartest_model() -> str:
("gpt-sol", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("deepseek-pro", LLMProvider.DEEPSEEK),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo-pro", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, fallback=_DEFAULT_FALLBACK_MODEL)
Expand All @@ -150,7 +150,7 @@ def pick_fastest_model() -> str:
("gpt-luna", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("deepseek-flash", LLMProvider.DEEPSEEK),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(
Expand Down Expand Up @@ -178,7 +178,7 @@ def pick_writing_model() -> str:
("anthropic/claude-sonnet", LLMProvider.OPENROUTER),
("gemini-pro", LLMProvider.GOOGLE),
("gpt-sol", LLMProvider.OPENAI),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("deepseek-pro", LLMProvider.DEEPSEEK),
("grok", LLMProvider.XAI),
("mimo-pro", LLMProvider.MIMO_PLAN),
Expand All @@ -190,7 +190,7 @@ def pick_chinese_writing_model() -> str:
"""Pick the best model for Chinese writing (Chinese-native models first)."""
order: list[tuple[str, LLMProvider]] = [
("qwen/qwen-max", LLMProvider.OPENROUTER),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo-pro", LLMProvider.MIMO_PLAN),
("deepseek-pro", LLMProvider.DEEPSEEK),
("gemini-pro", LLMProvider.GOOGLE),
Expand All @@ -208,7 +208,7 @@ def pick_finance_model() -> str:
("gemini-pro", LLMProvider.GOOGLE),
("gpt-sol", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo-pro", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, fallback=_DEFAULT_FALLBACK_MODEL)
Expand All @@ -222,7 +222,7 @@ def pick_search_model() -> str:
("gpt-terra", LLMProvider.OPENAI),
("x-ai/grok", LLMProvider.OPENROUTER),
("deepseek-flash", LLMProvider.DEEPSEEK),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, fallback=_DEFAULT_FALLBACK_MODEL)
Expand All @@ -236,7 +236,7 @@ def pick_broadest_knowledge_model() -> str:
("gpt-sol", LLMProvider.OPENAI),
("grok", LLMProvider.XAI),
("deepseek-pro", LLMProvider.DEEPSEEK),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo-pro", LLMProvider.MIMO_PLAN),
]
return _first_configured(order, fallback=_DEFAULT_FALLBACK_MODEL)
Expand All @@ -253,16 +253,24 @@ def pick_long_context_model() -> str:
("deepseek/deepseek-flash", LLMProvider.OPENROUTER),
("deepseek-flash", LLMProvider.DEEPSEEK),
("gpt-luna", LLMProvider.OPENAI),
("minimax", LLMProvider.MINIMAX),
("minimax-flash", LLMProvider.MINIMAX),
("mimo", LLMProvider.MIMO_PLAN),
]
return _first_configured(order)


def list_available_model_ids() -> list[str]:
"""Return the sorted, distinct model IDs available in this deployment.
"""Return distinct model IDs, alphabetically except MiniMax Flash before M3.

Reflects the providers configured at process start (``AVAILABLE_MODELS``),
which is fixed for the lifetime of a deployment.
"""
return sorted({model.id for model in AVAILABLE_MODELS.values()})
ids = sorted({model.id for model in AVAILABLE_MODELS.values() if model.selectable})
for flash, m3 in (
("minimax-flash", "minimax"),
("minimax/minimax-flash:free", "minimax/minimax"),
):
if flash in ids and m3 in ids:
first, second = sorted((ids.index(flash), ids.index(m3)))
ids[first], ids[second] = flash, m3
return ids
3 changes: 2 additions & 1 deletion tests/core/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,8 @@ def test_catalog_ids_are_versionless_series_ids():

rows = _catalog_rows()
for row in rows:
assert not re.search(r"\d", row["id"]), f"{row['id']} carries a version"
if row.get("selectable", True):
assert not re.search(r"\d", row["id"]), f"{row['id']} carries a version"
api_id = row.get("api_id", row["id"])
assert api_id, row["id"]
if api_id.startswith("~"):
Expand Down
6 changes: 5 additions & 1 deletion tests/core/test_origin_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ def test_all_openrouter_catalog_entries_pin_origin_provider():
unpinned = [
row["id"]
for row in rows
if row["provider"] == "openrouter" and not row.get("origin_provider")
if row["provider"] == "openrouter"
and row.get("selectable", True)
and not row.get("origin_provider")
]
assert unpinned == ["openrouter/free"]

Expand All @@ -118,6 +120,7 @@ async def fake_get(model_id: str) -> LLMModelInfo:
return info

monkeypatch.setattr(LLMModelInfo, "get", staticmethod(fake_get))
monkeypatch.setattr("intentkit.models.llm.config.openrouter_api_key", "test-key")

llm = OpenRouterLLM(model_name=info.id, info=info)
instance = await llm.create_instance()
Expand All @@ -138,6 +141,7 @@ async def fake_get(model_id: str) -> LLMModelInfo:
return info

monkeypatch.setattr(LLMModelInfo, "get", staticmethod(fake_get))
monkeypatch.setattr("intentkit.models.llm.config.openrouter_api_key", "test-key")

llm = OpenRouterLLM(model_name=info.id, info=info)
instance = await llm.create_instance()
Expand Down
1 change: 1 addition & 0 deletions tests/core/test_reasoning_effort.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ async def test_minimax_always_sends_thinking_toggle():
patch.object(LLMModelInfo, "get", return_value=info),
):
mock_config.minimax_plan_api_key = "test-key"
mock_config.minimax_base_url = "https://api.minimax.io/anthropic"
on = await MiniMaxLLM(model_name=info.id, info=info).create_instance()
off = await MiniMaxLLM(
model_name=info.id, reasoning_effort="none", info=info
Expand Down
Loading