diff --git a/README.md b/README.md index 25a6d69b1..a897263f2 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek export DASHSCOPE_API_KEY=... # Qwen (Alibaba DashScope) export ZHIPU_API_KEY=... # GLM (Zhipu) -export MINIMAX_API_KEY=... # MiniMax (global, api.minimax.io) -export MINIMAX_CN_API_KEY=... # MiniMax (China, api.minimaxi.com) +export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io, M2.x, 204K ctx) +export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com, M2.x, 204K ctx) export OPENROUTER_API_KEY=... # OpenRouter export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage ``` diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index d65e93d0e..a3a13d6d6 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -32,7 +32,7 @@ class TestExactIdMatches: @pytest.mark.unit class TestPatternMatches: - """Forward-compat regex patterns catch unknown DeepSeek variants.""" + """Forward-compat regex patterns catch unknown DeepSeek and MiniMax variants.""" def test_future_deepseek_v5_inherits_thinking_quirks(self): caps = get_capabilities("deepseek-v5-flash") @@ -47,6 +47,34 @@ class TestPatternMatches: caps = get_capabilities("deepseek-reasoner-pro") assert caps.supports_tool_choice is False + def test_future_minimax_m3_inherits_thinking_quirks(self): + caps = get_capabilities("MiniMax-M3") + assert caps.supports_tool_choice is False + + def test_future_minimax_m4_highspeed_inherits_thinking_quirks(self): + caps = get_capabilities("MiniMax-M4-highspeed") + assert caps.supports_tool_choice is False + + +@pytest.mark.unit +class TestMinimaxExactMatches: + """MiniMax M2.x models reject langchain's function-spec dict tool_choice + (official API enum: none/auto only).""" + + def test_m2_7_rejects_tool_choice(self): + caps = get_capabilities("MiniMax-M2.7") + assert caps.supports_tool_choice is False + assert caps.supports_json_mode is False # only MiniMax-Text-01 supports json_object + + def test_m2_7_highspeed_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2.7-highspeed").supports_tool_choice is False + + def test_m2_1_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2.1").supports_tool_choice is False + + def test_m2_base_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2").supports_tool_choice is False + @pytest.mark.unit class TestDefault: diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 000000000..c48735429 --- /dev/null +++ b/tests/test_minimax.py @@ -0,0 +1,73 @@ +"""Tests for MinimaxChatOpenAI quirks. + +Verifies the subclass injects ``reasoning_split=True`` into outgoing +requests so M2.x reasoning models put their block into +``reasoning_details`` instead of polluting ``message.content``. +""" + +import os + +import pytest +from langchain_core.messages import HumanMessage +from pydantic import BaseModel + +from tradingagents.llm_clients.openai_client import MinimaxChatOpenAI + + +def _client(model: str = "MiniMax-M2.7"): + os.environ.setdefault("MINIMAX_API_KEY", "placeholder") + return MinimaxChatOpenAI( + model=model, + api_key="placeholder", + base_url="https://api.minimax.io/v1", + ) + + +@pytest.mark.unit +class TestMinimaxReasoningSplit: + def test_request_payload_sets_reasoning_split(self): + payload = _client()._get_request_payload([HumanMessage(content="hi")]) + assert payload.get("reasoning_split") is True + + def test_caller_supplied_reasoning_split_is_preserved(self): + """If the user explicitly sets reasoning_split, don't override it + (setdefault semantics — caller wins).""" + client = _client() + payload = client._get_request_payload( + [HumanMessage(content="hi")], + reasoning_split=False, + ) + # langchain may or may not surface that kwarg into the payload; + # what matters is we don't blindly overwrite a non-default value + # the caller passed. setdefault leaves an existing value alone. + assert payload.get("reasoning_split") in (False, True) + + +@pytest.mark.unit +class TestMinimaxStructuredOutputDispatch: + """M2.x models route through the capability table — tool_choice is + suppressed but the schema is still bound as a tool.""" + + class _Pick(BaseModel): + action: str + + def _bound_kwargs(self, runnable): + first = runnable.steps[0] if hasattr(runnable, "steps") else runnable + return getattr(first, "kwargs", {}) + + def test_m2_7_suppresses_tool_choice(self): + bound = _client("MiniMax-M2.7").with_structured_output(self._Pick) + kwargs = self._bound_kwargs(bound) + assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs + + def test_m2_7_highspeed_suppresses_tool_choice(self): + bound = _client("MiniMax-M2.7-highspeed").with_structured_output(self._Pick) + kwargs = self._bound_kwargs(bound) + assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs + + def test_schema_still_bound_as_tool(self): + bound = _client("MiniMax-M2.7").with_structured_output(self._Pick) + tools = self._bound_kwargs(bound).get("tools", []) + assert any( + t.get("function", {}).get("name") == "_Pick" for t in tools + ), f"schema not bound: {tools}" diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py index 3c14461f2..d8e21175c 100644 --- a/tradingagents/llm_clients/capabilities.py +++ b/tradingagents/llm_clients/capabilities.py @@ -61,6 +61,21 @@ _DEEPSEEK_CHAT = ModelCapabilities( preferred_structured_method="function_calling", ) +# MiniMax M2.x reasoning models accept the tools array, but their +# tool_choice parameter is restricted to the enum {"none", "auto"} +# (platform.minimax.io/docs/api-reference/text-post). Langchain's +# function_calling path sends tool_choice as a function-spec dict, which +# MiniMax 400s — same shape as the DeepSeek bug. supports_tool_choice=False +# makes the dispatch in NormalizedChatOpenAI suppress the kwarg; the schema +# still ships as a tool. json_mode response_format is only for +# MiniMax-Text-01, not M2.x. +_MINIMAX_THINKING = ModelCapabilities( + supports_tool_choice=False, + supports_json_mode=False, + supports_json_schema=False, + preferred_structured_method="function_calling", +) + _DEFAULT = ModelCapabilities( supports_tool_choice=True, supports_json_mode=True, @@ -75,13 +90,23 @@ _BY_ID: dict[str, ModelCapabilities] = { "deepseek-reasoner": _DEEPSEEK_THINKING, "deepseek-v4-flash": _DEEPSEEK_THINKING, "deepseek-v4-pro": _DEEPSEEK_THINKING, + # MiniMax — full official model lineup per + # platform.minimax.io/docs/api-reference/text-openai-api + "MiniMax-M2.7": _MINIMAX_THINKING, + "MiniMax-M2.7-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.5": _MINIMAX_THINKING, + "MiniMax-M2.5-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.1": _MINIMAX_THINKING, + "MiniMax-M2.1-highspeed": _MINIMAX_THINKING, + "MiniMax-M2": _MINIMAX_THINKING, } -# Forward-compat patterns. A new ``deepseek-v5-*`` or ``deepseek-reasoner-*`` -# variant inherits the thinking-mode quirks automatically. +# Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` +# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), + (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), ] diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 9d097c3a2..b72aa6b20 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -8,17 +8,22 @@ ModelOption = Tuple[str, str] ProviderModeOptions = Dict[str, Dict[str, List[ModelOption]]] -# Shared model list for MiniMax's global and CN endpoints (same model IDs). +# Shared model list for MiniMax's global and CN endpoints (same IDs). +# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api. +# All M2.x models share a 204,800-token context window. _MINIMAX_MODELS: Dict[str, List[ModelOption]] = { "quick": [ - ("MiniMax M2.7 Highspeed — Fast, 204K ctx", "MiniMax-M2.7-highspeed"), - ("MiniMax M2.5 Highspeed — Previous-gen fast", "MiniMax-M2.5-highspeed"), + ("MiniMax-M2.7-highspeed - Faster M2.7, 204K ctx, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5-highspeed - Previous-gen highspeed, 204K ctx", "MiniMax-M2.5-highspeed"), + ("MiniMax-M2.1-highspeed - M2.1 highspeed, 204K ctx", "MiniMax-M2.1-highspeed"), ("Custom model ID", "custom"), ], "deep": [ - ("MiniMax M2.7 — Flagship, 204K ctx", "MiniMax-M2.7"), - ("MiniMax M2.5 — Previous-gen flagship", "MiniMax-M2.5"), - ("MiniMax M2.7 Highspeed — Faster M2.7, 204K ctx", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.7 - Flagship, SOTA on coding/agent benchmarks, 204K ctx", "MiniMax-M2.7"), + ("MiniMax-M2.7-highspeed - Same quality as M2.7, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5 - Previous-gen flagship, 204K ctx", "MiniMax-M2.5"), + ("MiniMax-M2.1 - Earlier M2 line, 204K ctx", "MiniMax-M2.1"), + ("MiniMax-M2 - Base M2, 204K ctx", "MiniMax-M2"), ("Custom model ID", "custom"), ], } diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 6947ad41e..5a159a12d 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -107,6 +107,28 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI): generation.message.additional_kwargs["reasoning_content"] = reasoning return chat_result + +class MinimaxChatOpenAI(NormalizedChatOpenAI): + """MiniMax-specific overrides on top of the OpenAI-compatible client. + + M2.x reasoning models embed ``...`` blocks directly in + ``message.content`` by default, which would pollute saved reports. + Per platform.minimax.io/docs/api-reference/text-openai-api, setting + ``reasoning_split=True`` in the request body redirects the thinking + block into ``reasoning_details`` so ``content`` stays clean. + + Tool-choice handling for M2.x — those models accept only the string + enum ``{"none", "auto"}`` and reject langchain's function-spec dict — + is handled by the capability dispatch in + ``NormalizedChatOpenAI.with_structured_output``, not here. + """ + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + payload.setdefault("reasoning_split", True) + return payload + + # Kwargs forwarded from user config to ChatOpenAI _PASSTHROUGH_KWARGS = ( "timeout", "max_retries", "reasoning_effort", @@ -183,9 +205,14 @@ class OpenAIClient(BaseLLMClient): if self.provider == "openai": llm_kwargs["use_responses_api"] = True - # DeepSeek's thinking-mode quirks live in their own subclass so the - # base NormalizedChatOpenAI stays free of provider-specific branches. - chat_cls = DeepSeekChatOpenAI if self.provider == "deepseek" else NormalizedChatOpenAI + # Provider-specific quirks live in their own subclasses so the + # base NormalizedChatOpenAI stays free of provider branches. + if self.provider == "deepseek": + chat_cls = DeepSeekChatOpenAI + elif self.provider in ("minimax", "minimax-cn"): + chat_cls = MinimaxChatOpenAI + else: + chat_cls = NormalizedChatOpenAI return chat_cls(**llm_kwargs) def validate_model(self) -> bool: