/ tests / agent / test_minimax_provider.py
test_minimax_provider.py
  1  """Tests for MiniMax provider hardening — context lengths, thinking, catalog, beta headers, transport."""
  2  
  3  from unittest.mock import patch
  4  
  5  
  6  class TestMinimaxContextLengths:
  7      """Verify context length entries match official docs (204,800 for all models).
  8  
  9      Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
 10      """
 11  
 12      def test_minimax_prefix_has_correct_context(self):
 13          from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
 14          assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 204_800
 15  
 16      def test_minimax_models_resolve_via_prefix(self):
 17          from agent.model_metadata import get_model_context_length
 18          # All MiniMax models should resolve to 204,800 via the "minimax" prefix
 19          for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
 20              ctx = get_model_context_length(model, "")
 21              assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
 22  
 23  
 24  
 25  class TestMinimaxThinkingSupport:
 26      """Verify that MiniMax gets manual thinking (not adaptive).
 27  
 28      MiniMax's Anthropic-compat endpoint officially supports the thinking
 29      parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api).
 30      It should get manual thinking (type=enabled + budget_tokens), NOT adaptive
 31      thinking (which is Claude 4.6-only).
 32      """
 33  
 34      def test_minimax_m27_gets_manual_thinking(self):
 35          from agent.anthropic_adapter import build_anthropic_kwargs
 36          kwargs = build_anthropic_kwargs(
 37              model="MiniMax-M2.7",
 38              messages=[{"role": "user", "content": "hello"}],
 39              tools=None,
 40              max_tokens=4096,
 41              reasoning_config={"enabled": True, "effort": "medium"},
 42          )
 43          assert "thinking" in kwargs
 44          assert kwargs["thinking"]["type"] == "enabled"
 45          assert "budget_tokens" in kwargs["thinking"]
 46          # MiniMax should NOT get adaptive thinking or output_config
 47          assert "output_config" not in kwargs
 48  
 49      def test_minimax_m25_gets_manual_thinking(self):
 50          from agent.anthropic_adapter import build_anthropic_kwargs
 51          kwargs = build_anthropic_kwargs(
 52              model="MiniMax-M2.5",
 53              messages=[{"role": "user", "content": "hello"}],
 54              tools=None,
 55              max_tokens=4096,
 56              reasoning_config={"enabled": True, "effort": "high"},
 57          )
 58          assert "thinking" in kwargs
 59          assert kwargs["thinking"]["type"] == "enabled"
 60  
 61      def test_thinking_still_works_for_claude(self):
 62          from agent.anthropic_adapter import build_anthropic_kwargs
 63          kwargs = build_anthropic_kwargs(
 64              model="claude-sonnet-4-20250514",
 65              messages=[{"role": "user", "content": "hello"}],
 66              tools=None,
 67              max_tokens=4096,
 68              reasoning_config={"enabled": True, "effort": "medium"},
 69          )
 70          assert "thinking" in kwargs
 71  
 72  
 73  class TestMinimaxAuxModel:
 74      """Verify auxiliary model is standard (not highspeed)."""
 75  
 76      def test_minimax_aux_is_standard(self):
 77          from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
 78          assert _API_KEY_PROVIDER_AUX_MODELS["minimax"] == "MiniMax-M2.7"
 79          assert _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"] == "MiniMax-M2.7"
 80  
 81      def test_minimax_aux_not_highspeed(self):
 82          from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
 83          assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax"]
 84          assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"]
 85  
 86  
 87  class TestMinimaxBetaHeaders:
 88      """MiniMax Anthropic-compat endpoints reject fine-grained-tool-streaming beta.
 89  
 90      Verify that build_anthropic_client omits the tool-streaming beta for MiniMax
 91      (both global and China domains) while keeping it for native Anthropic and
 92      other third-party endpoints.  Covers the fix for #6510 / #6555.
 93      """
 94  
 95      _TOOL_BETA = "fine-grained-tool-streaming-2025-05-14"
 96      _THINKING_BETA = "interleaved-thinking-2025-05-14"
 97  
 98      # -- helper ----------------------------------------------------------
 99  
100      def _build_and_get_betas(self, api_key, base_url=None):
101          """Build client, return the anthropic-beta header string."""
102          from agent.anthropic_adapter import build_anthropic_client
103          with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
104              build_anthropic_client(api_key, base_url=base_url)
105              kwargs = mock_sdk.Anthropic.call_args[1]
106              headers = kwargs.get("default_headers", {})
107              return headers.get("anthropic-beta", "")
108  
109      # -- MiniMax global --------------------------------------------------
110  
111      def test_minimax_global_omits_tool_streaming(self):
112          betas = self._build_and_get_betas(
113              "mm-key-123", base_url="https://api.minimax.io/anthropic"
114          )
115          assert self._TOOL_BETA not in betas
116          assert self._THINKING_BETA in betas
117  
118      def test_minimax_global_trailing_slash(self):
119          betas = self._build_and_get_betas(
120              "mm-key-123", base_url="https://api.minimax.io/anthropic/"
121          )
122          assert self._TOOL_BETA not in betas
123  
124      # -- MiniMax China ---------------------------------------------------
125  
126      def test_minimax_cn_omits_tool_streaming(self):
127          betas = self._build_and_get_betas(
128              "mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic"
129          )
130          assert self._TOOL_BETA not in betas
131          assert self._THINKING_BETA in betas
132  
133      def test_minimax_cn_trailing_slash(self):
134          betas = self._build_and_get_betas(
135              "mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic/"
136          )
137          assert self._TOOL_BETA not in betas
138  
139      # -- Non-MiniMax keeps full betas ------------------------------------
140  
141      def test_native_anthropic_keeps_tool_streaming(self):
142          betas = self._build_and_get_betas("sk-ant-api03-real-key-here")
143          assert self._TOOL_BETA in betas
144          assert self._THINKING_BETA in betas
145  
146      def test_third_party_proxy_keeps_tool_streaming(self):
147          betas = self._build_and_get_betas(
148              "custom-key", base_url="https://my-proxy.example.com/anthropic"
149          )
150          assert self._TOOL_BETA in betas
151  
152      def test_custom_base_url_keeps_tool_streaming(self):
153          betas = self._build_and_get_betas(
154              "custom-key", base_url="https://custom.api.com"
155          )
156          assert self._TOOL_BETA in betas
157  
158      # -- _common_betas_for_base_url unit tests ---------------------------
159  
160      def test_common_betas_none_url(self):
161          from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
162          assert _common_betas_for_base_url(None) == _COMMON_BETAS
163  
164      def test_common_betas_empty_url(self):
165          from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
166          assert _common_betas_for_base_url("") == _COMMON_BETAS
167  
168      def test_common_betas_minimax_url(self):
169          from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
170          betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
171          assert _TOOL_STREAMING_BETA not in betas
172          assert len(betas) > 0  # still has other betas
173  
174      def test_common_betas_minimax_cn_url(self):
175          from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
176          betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
177          assert _TOOL_STREAMING_BETA not in betas
178  
179      def test_common_betas_regular_url(self):
180          from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
181          assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
182  
183  
184  class TestMinimaxApiMode:
185      """Verify determine_api_mode returns anthropic_messages for MiniMax providers.
186  
187      The MiniMax /anthropic endpoint speaks Anthropic Messages wire format,
188      not OpenAI chat completions.  The overlay transport must reflect this
189      so that code paths calling determine_api_mode() without a base_url
190      (e.g. /model switch) get the correct api_mode.
191      """
192  
193      def test_minimax_returns_anthropic_messages(self):
194          from hermes_cli.providers import determine_api_mode
195          assert determine_api_mode("minimax") == "anthropic_messages"
196  
197      def test_minimax_cn_returns_anthropic_messages(self):
198          from hermes_cli.providers import determine_api_mode
199          assert determine_api_mode("minimax-cn") == "anthropic_messages"
200  
201      def test_minimax_with_url_also_works(self):
202          from hermes_cli.providers import determine_api_mode
203          # Even with explicit base_url, provider lookup takes priority
204          assert determine_api_mode("minimax", "https://api.minimax.io/anthropic") == "anthropic_messages"
205  
206      def test_anthropic_still_returns_anthropic_messages(self):
207          from hermes_cli.providers import determine_api_mode
208          assert determine_api_mode("anthropic") == "anthropic_messages"
209  
210      def test_openai_returns_chat_completions(self):
211          from hermes_cli.providers import determine_api_mode
212          # Sanity check: standard providers are unaffected
213          result = determine_api_mode("deepseek")
214          assert result == "chat_completions"
215  
216  
217  class TestMinimaxMaxOutput:
218      """Verify _get_anthropic_max_output returns correct limits for MiniMax models.
219  
220      MiniMax max output is 131,072 tokens (source: OpenClaw model definitions,
221      cross-referenced with MiniMax API behavior).
222      """
223  
224      def test_minimax_m27_output_limit(self):
225          from agent.anthropic_adapter import _get_anthropic_max_output
226          assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
227  
228      def test_minimax_m25_output_limit(self):
229          from agent.anthropic_adapter import _get_anthropic_max_output
230          assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
231  
232      def test_minimax_m2_output_limit(self):
233          from agent.anthropic_adapter import _get_anthropic_max_output
234          assert _get_anthropic_max_output("MiniMax-M2") == 131_072
235  
236      def test_claude_output_unaffected(self):
237          from agent.anthropic_adapter import _get_anthropic_max_output
238          # Sanity: Claude limits are not broken by the MiniMax entry
239          assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
240  
241  
242  class TestMinimaxPreserveDots:
243      """Verify that MiniMax model names preserve dots through the Anthropic adapter.
244  
245      MiniMax model IDs like 'MiniMax-M2.7' must NOT have dots converted to
246      hyphens — the endpoint expects the exact name with dots.
247      """
248  
249      def test_minimax_provider_preserves_dots(self):
250          from types import SimpleNamespace
251          agent = SimpleNamespace(provider="minimax", base_url="")
252          from run_agent import AIAgent
253          assert AIAgent._anthropic_preserve_dots(agent) is True
254  
255      def test_minimax_cn_provider_preserves_dots(self):
256          from types import SimpleNamespace
257          agent = SimpleNamespace(provider="minimax-cn", base_url="")
258          from run_agent import AIAgent
259          assert AIAgent._anthropic_preserve_dots(agent) is True
260  
261      def test_minimax_url_preserves_dots(self):
262          from types import SimpleNamespace
263          agent = SimpleNamespace(provider="custom", base_url="https://api.minimax.io/anthropic")
264          from run_agent import AIAgent
265          assert AIAgent._anthropic_preserve_dots(agent) is True
266  
267      def test_minimax_cn_url_preserves_dots(self):
268          from types import SimpleNamespace
269          agent = SimpleNamespace(provider="custom", base_url="https://api.minimaxi.com/anthropic")
270          from run_agent import AIAgent
271          assert AIAgent._anthropic_preserve_dots(agent) is True
272  
273      def test_anthropic_does_not_preserve_dots(self):
274          from types import SimpleNamespace
275          agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
276          from run_agent import AIAgent
277          assert AIAgent._anthropic_preserve_dots(agent) is False
278  
279      def test_opencode_zen_provider_preserves_dots(self):
280          from types import SimpleNamespace
281          agent = SimpleNamespace(provider="opencode-zen", base_url="")
282          from run_agent import AIAgent
283          assert AIAgent._anthropic_preserve_dots(agent) is True
284  
285      def test_opencode_zen_url_preserves_dots(self):
286          from types import SimpleNamespace
287          agent = SimpleNamespace(provider="custom", base_url="https://opencode.ai/zen/v1")
288          from run_agent import AIAgent
289          assert AIAgent._anthropic_preserve_dots(agent) is True
290  
291      def test_zai_provider_preserves_dots(self):
292          from types import SimpleNamespace
293          agent = SimpleNamespace(provider="zai", base_url="")
294          from run_agent import AIAgent
295          assert AIAgent._anthropic_preserve_dots(agent) is True
296  
297      def test_bigmodel_cn_url_preserves_dots(self):
298          from types import SimpleNamespace
299          agent = SimpleNamespace(provider="custom", base_url="https://open.bigmodel.cn/api/paas/v4")
300          from run_agent import AIAgent
301          assert AIAgent._anthropic_preserve_dots(agent) is True
302  
303      def test_normalize_preserves_m25_free_dot(self):
304          from agent.anthropic_adapter import normalize_model_name
305          assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free"
306  
307      def test_normalize_preserves_m27_dot(self):
308          from agent.anthropic_adapter import normalize_model_name
309          assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
310  
311      def test_normalize_preserves_non_anthropic_dots_without_preserve(self):
312          from agent.anthropic_adapter import normalize_model_name
313          # Non-Anthropic model families use dots as canonical version separators;
314          # only Claude/Anthropic names are hyphen-normalized by default.
315          assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7"
316  
317      def test_normalize_still_converts_claude_dots_without_preserve(self):
318          from agent.anthropic_adapter import normalize_model_name
319          assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6"
320  
321  
322  class TestMinimaxSwitchModelCredentialGuard:
323      """Verify switch_model() does not leak Anthropic credentials to MiniMax.
324  
325      The __init__ path correctly guards against this (line 761), but switch_model()
326      must mirror that guard. Without it, /model switch to minimax with no explicit
327      api_key would fall back to resolve_anthropic_token() and send Anthropic creds
328      to the MiniMax endpoint.
329      """
330  
331      def test_switch_to_minimax_does_not_resolve_anthropic_token(self):
332          """switch_model() should NOT call resolve_anthropic_token() for MiniMax."""
333          from unittest.mock import patch, MagicMock
334  
335          with patch("run_agent.AIAgent.__init__", return_value=None):
336              from run_agent import AIAgent
337              agent = AIAgent.__new__(AIAgent)
338              agent.provider = "anthropic"
339              agent.model = "claude-sonnet-4"
340              agent.api_key = "sk-ant-fake"
341              agent.base_url = "https://api.anthropic.com"
342              agent.api_mode = "anthropic_messages"
343              agent._anthropic_base_url = "https://api.anthropic.com"
344              agent._anthropic_api_key = "sk-ant-fake"
345              agent._is_anthropic_oauth = False
346              agent._client_kwargs = {}
347              agent.client = None
348              agent._anthropic_client = MagicMock()
349              agent._fallback_chain = []
350  
351          with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
352               patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
353               patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
354  
355              agent.switch_model(
356                  new_model="MiniMax-M2.7",
357                  new_provider="minimax",
358                  api_mode="anthropic_messages",
359                  api_key="mm-key-123",
360                  base_url="https://api.minimax.io/anthropic",
361              )
362              # resolve_anthropic_token should NOT be called for non-Anthropic providers
363              mock_resolve.assert_not_called()
364              # The key passed to build_anthropic_client should be the MiniMax key
365              build_args = mock_build.call_args
366              assert build_args[0][0] == "mm-key-123"