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"