diff --git a/.env.example b/.env.example index 0d06d7f36..ca59694d9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/intentkit/config/config.py b/intentkit/config/config.py index 4fffceebd..7fe1aebc2 100644 --- a/intentkit/config/config.py +++ b/intentkit/config/config.py @@ -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 diff --git a/intentkit/models/llm.py b/intentkit/models/llm.py index 244d21e2d..74026657f 100644 --- a/intentkit/models/llm.py +++ b/intentkit/models/llm.py @@ -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 @@ -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 @@ -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} @@ -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) diff --git a/intentkit/models/llm.yaml b/intentkit/models/llm.yaml index 7327ab9e3..210d91bf6 100644 --- a/intentkit/models/llm.yaml +++ b/intentkit/models/llm.yaml @@ -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" @@ -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" diff --git a/intentkit/models/llm_picker.py b/intentkit/models/llm_picker.py index 8ffdc993f..c270856ca 100644 --- a/intentkit/models/llm_picker.py +++ b/intentkit/models/llm_picker.py @@ -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) @@ -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), @@ -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) @@ -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( @@ -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) @@ -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( @@ -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), @@ -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), @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/tests/core/test_llm.py b/tests/core/test_llm.py index bf819250c..8cbbbe9f4 100644 --- a/tests/core/test_llm.py +++ b/tests/core/test_llm.py @@ -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("~"): diff --git a/tests/core/test_origin_provider.py b/tests/core/test_origin_provider.py index 2d69ea398..9a66079b7 100644 --- a/tests/core/test_origin_provider.py +++ b/tests/core/test_origin_provider.py @@ -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"] @@ -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() @@ -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() diff --git a/tests/core/test_reasoning_effort.py b/tests/core/test_reasoning_effort.py index 2cbdff136..1abcc3392 100644 --- a/tests/core/test_reasoning_effort.py +++ b/tests/core/test_reasoning_effort.py @@ -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 diff --git a/tests/models/test_llm_picker.py b/tests/models/test_llm_picker.py index c63c4bda3..2898af89a 100644 --- a/tests/models/test_llm_picker.py +++ b/tests/models/test_llm_picker.py @@ -232,9 +232,11 @@ def test_category_picker_different_providers_yield_different_models(): # ── list_available_model_ids ───────────────────────────────────────── -def test_list_available_model_ids_returns_sorted_unique_strings(): - """Returns a sorted list of unique model-id strings (possibly empty).""" +def test_list_available_model_ids_returns_ordered_unique_strings(): + """Non-MiniMax choices keep alphabetical order; all IDs remain unique.""" result = list_available_model_ids() assert isinstance(result, list) assert all(isinstance(mid, str) for mid in result) - assert result == sorted(set(result)) + assert len(result) == len(set(result)) + non_minimax = [mid for mid in result if "minimax" not in mid.lower()] + assert non_minimax == sorted(non_minimax) diff --git a/tests/models/test_minimax_provider.py b/tests/models/test_minimax_provider.py new file mode 100644 index 000000000..dff0129d7 --- /dev/null +++ b/tests/models/test_minimax_provider.py @@ -0,0 +1,338 @@ +"""MiniMax rollout contracts at the real LangChain/SDK HTTP boundary (offline).""" + +import json +from decimal import Decimal +from unittest.mock import patch + +import httpx +import httpx2 +import pytest +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from intentkit.config.config import Config +from intentkit.models import llm, llm_picker + + +@pytest.fixture +def catalog(monkeypatch): + monkeypatch.setattr(llm.config, "minimax_plan_api_key", "test-minimax-key") + monkeypatch.setattr(llm.config, "openrouter_api_key", "test-openrouter-key") + monkeypatch.setattr( + llm.config, "minimax_base_url", "https://api.minimax.io/anthropic" + ) + models = llm.load_default_llm_models() + monkeypatch.setattr(llm, "AVAILABLE_MODELS", models) + monkeypatch.setattr(llm, "_MODEL_ID_INDEX", llm.build_model_id_index(models)) + monkeypatch.setattr(llm_picker, "AVAILABLE_MODELS", models) + return models + + +@pytest.fixture +def wire(monkeypatch): + """Intercept only HTTP sends; model construction and serialization stay real.""" + requests = [] + + def install(module): + async def send(self, request, **kwargs): + body = json.loads(request.content) + requests.append((str(request.url), body)) + if request.url.path.endswith("/messages"): + response = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": body["model"], + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + else: + response = { + "id": "chatcmpl_test", + "object": "chat.completion", + "system_fingerprint": None, + "created": 0, + "model": body["model"], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + return module.Response(200, json=response, request=request) + + monkeypatch.setattr(module.AsyncClient, "send", send) + + install(httpx) + install(httpx2) + return requests + + +def test_catalog_prices_capabilities_and_order(catalog): + direct = [ + m + for m in catalog.values() + if m.provider == llm.LLMProvider.MINIMAX and m.selectable + ] + assert [m.api_id for m in direct] == ["MiniMax-M3.1-Flash", "MiniMax-M3"] + flash = direct[0] + assert (flash.context_length, flash.output_length) == (1_000_000, 524_288) + assert (flash.input_price, flash.output_price, flash.cached_input_price) == ( + Decimal("0.15"), + Decimal("0.60"), + Decimal("0.015"), + ) + assert flash.supports_image_input and flash.supports_video_input + assert not flash.supports_audio_input + assert flash.cost_usd(1_000_000, 1_000_000, 100_000) == Decimal("0.7365") + gateway = [ + m + for m in catalog.values() + if m.provider == llm.LLMProvider.OPENROUTER + and m.id.startswith("minimax/") + and m.selectable + ] + assert [m.api_id for m in gateway] == [ + "minimax/minimax-m3.1-flash:free", + "minimax/minimax-m3", + ] + assert gateway[0].input_price == gateway[0].output_price == 0 + assert gateway[0].origin_provider == "minimax" + # Preserve the pinned gateway M3 contract rather than copying direct limits. + assert (gateway[1].context_length, gateway[1].output_length) == (524_288, 512_000) + assert gateway[1].origin_provider == "minimax/fp8" + + +@pytest.mark.asyncio +async def test_hidden_legacy_remains_explicitly_routable(catalog): + choices = llm_picker.list_available_model_ids() + assert choices.index("minimax-flash") < choices.index("minimax") + assert choices.index("minimax/minimax-flash:free") < choices.index( + "minimax/minimax" + ) + for model_id in ("MiniMax-M2.7", "minimax/minimax-m2-her"): + assert model_id not in choices + assert model_id not in [m.id for m in await llm.LLMModelInfo.get_all()] + assert (await llm.LLMModelInfo.get(model_id)).api_id == model_id + assert llm.resolve_model_id(model_id) == model_id + assert (await llm.LLMModelInfo.get("minimax")).api_id == "MiniMax-M3" + assert (await llm.LLMModelInfo.get("MiniMax-M3")).api_id == "MiniMax-M3" + assert ( + await llm.LLMModelInfo.get("minimax/minimax") + ).api_id == "minimax/minimax-m3" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider", [llm.LLMProvider.MINIMAX, llm.LLMProvider.OPENROUTER] +) +async def test_default_picker_reaches_real_sdk(catalog, wire, provider): + with patch.object( + llm.LLMProvider, "is_configured", property(lambda self: self == provider) + ): + selected = llm_picker.pick_default_model() + adapter = await llm.create_llm_model(selected) + model = await adapter.create_instance() + await model.ainvoke("hello") + url, body = wire[-1] + assert "service_tier" not in body + if provider == llm.LLMProvider.MINIMAX: + assert url == "https://api.minimax.io/anthropic/v1/messages" + assert body["model"] == "MiniMax-M3.1-Flash" + assert body["thinking"] == {"type": "adaptive"} + assert body["output_config"] == {"effort": "max"} + else: + assert url == "https://openrouter.ai/api/v1/chat/completions" + assert body["model"] == "minimax/minimax-m3.1-flash:free" + assert body["reasoning"] == {"effort": "max"} + assert body["provider"] == {"order": ["minimax"], "allow_fallbacks": False} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"]) +async def test_flash_effort_at_http_boundary(catalog, wire, effort): + adapter = await llm.create_llm_model("MiniMax-M3.1-Flash", effort) + model = await adapter.create_instance() + await model.ainvoke("hello") + assert wire[-1][1]["output_config"] == {"effort": effort} + assert wire[-1][1]["thinking"] == {"type": "adaptive"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("effort", ["none", "minimal"]) +@pytest.mark.parametrize("model_id", ["minimax-flash", "minimax/minimax-flash:free"]) +async def test_flash_rejects_unsupported_effort_before_http( + catalog, wire, effort, model_id +): + adapter = await llm.create_llm_model(model_id, effort) + with pytest.raises(ValueError, match="requires low/medium/high/xhigh/max"): + await adapter.create_instance() + assert wire == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_id", ["minimax", "MiniMax-M3", "minimax:MiniMax-M3", "MiniMax-M2.7"] +) +@pytest.mark.parametrize("effort", [None, "none", "high"]) +async def test_saved_models_keep_wire_id_and_no_effort_depth( + catalog, wire, model_id, effort +): + adapter = await llm.create_llm_model(model_id, effort) + model = await adapter.create_instance() + await model.ainvoke("hello") + body = wire[-1][1] + assert body["model"] == ( + "MiniMax-M2.7" if model_id == "MiniMax-M2.7" else "MiniMax-M3" + ) + assert "output_config" not in body + if model_id == "MiniMax-M2.7": + assert "thinking" not in body + elif effort == "none": + assert body["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "base_url", + [ + "https://api.minimax.io/anthropic", + "https://api.minimaxi.com/anthropic", + "https://custom.example/anthropic", + ], +) +async def test_configured_endpoint_tools_images_and_reasoning_history( + catalog, wire, monkeypatch, base_url +): + # Endpoint preservation is tested offline; this does not claim regional entitlement. + monkeypatch.setattr(llm.config, "minimax_base_url", base_url) + adapter = await llm.create_llm_model("minimax-flash") + model = await adapter.create_instance() + tool = { + "name": "lookup", + "description": "Look up a value", + "input_schema": {"type": "object", "properties": {}}, + } + thinking = { + "type": "thinking", + "thinking": "Keep this reasoning.", + "signature": "test-signature", + } + image = { + "type": "image", + "source": {"type": "url", "url": "https://example.com/image.png"}, + } + history = [ + HumanMessage(content=[{"type": "text", "text": "Read this"}, image]), + AIMessage( + content=[ + thinking, + {"type": "tool_use", "id": "call_test", "name": "lookup", "input": {}}, + ] + ), + ToolMessage(content="found", tool_call_id="call_test"), + ] + await model.bind_tools([tool]).ainvoke(history) + url, body = wire[-1] + assert url == base_url + "/v1/messages" + assert body["tools"][0]["name"] == "lookup" + assert body["messages"][0]["content"][1] == image + assert body["messages"][1]["content"][0] == thinking + assert body["messages"][1]["content"][1]["id"] == "call_test" + assert body["messages"][2]["content"][0]["tool_use_id"] == "call_test" + + +@pytest.mark.asyncio +async def test_gateway_tools_images_and_reasoning_history(catalog, wire): + adapter = await llm.create_llm_model("minimax/minimax-flash:free") + model = await adapter.create_instance() + details = [{"type": "reasoning.text", "text": "Keep reasoning", "index": 0}] + image = {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}} + history = [ + HumanMessage(content=[{"type": "text", "text": "Read this"}, image]), + AIMessage( + content="", + additional_kwargs={"reasoning_details": details}, + tool_calls=[{"id": "call_test", "name": "lookup", "args": {}}], + ), + ToolMessage(content="found", tool_call_id="call_test"), + ] + tool = { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up", + "parameters": {"type": "object", "properties": {}}, + }, + } + await model.bind_tools([tool]).ainvoke(history) + body = wire[-1][1] + assert body["messages"][0]["content"][1] == image + assert body["messages"][1]["reasoning_details"] == details + assert body["messages"][1]["tool_calls"][0]["id"] == "call_test" + assert body["messages"][2]["tool_call_id"] == "call_test" + assert body["tools"][0]["function"]["name"] == "lookup" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id", ["minimax/minimax", "minimax/minimax-m2-her"]) +async def test_saved_gateway_wire_ids(catalog, wire, model_id): + adapter = await llm.create_llm_model(model_id) + model = await adapter.create_instance() + await model.ainvoke("hello") + expected = "minimax/minimax-m3" if model_id == "minimax/minimax" else model_id + assert wire[-1][1]["model"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +async def test_custom_compatible_model_preserved(catalog, wire, monkeypatch, protocol): + prefix = protocol + "_compatible" + base_url = "https://custom.example/" + ( + "v1" if protocol == "openai" else "anthropic" + ) + for suffix, value in ( + ("api_key", "test-custom-key"), + ("base_url", base_url), + ("model", "MiniMax-private-saved"), + ): + monkeypatch.setattr(llm.config, prefix + "_" + suffix, value) + models = llm.load_default_llm_models() + monkeypatch.setattr(llm, "AVAILABLE_MODELS", models) + monkeypatch.setattr(llm, "_MODEL_ID_INDEX", llm.build_model_id_index(models)) + adapter = await llm.create_llm_model("MiniMax-private-saved") + model = await adapter.create_instance() + await model.ainvoke("hello") + url, body = wire[-1] + assert url == base_url + ( + "/chat/completions" if protocol == "openai" else "/v1/messages" + ) + assert body["model"] == "MiniMax-private-saved" + assert "output_config" not in body + + +@pytest.mark.parametrize("legacy", [True, False]) +def test_config_aliases_preserve_endpoint_and_key(monkeypatch, legacy): + monkeypatch.setenv("REDIS_HOST", "localhost") + for key in ( + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + "MINIMAX_PLAN_API_KEY", + "MINIMAX_PLAN_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + prefix = "MINIMAX_PLAN" if legacy else "MINIMAX" + monkeypatch.setenv(prefix + "_API_KEY", "test-config-key") + monkeypatch.setenv(prefix + "_BASE_URL", "https://custom.example/anthropic") + monkeypatch.setattr(Config, "_setup_langfuse", lambda self: None) + cfg = Config() + assert cfg.minimax_plan_api_key == "test-config-key" + assert cfg.minimax_base_url == "https://custom.example/anthropic"