diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 0d59d94c9c32..5c87aaf496e0 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -612,6 +612,32 @@ def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: return "azure.com" in normalized +_MINIMAX_M3_CANONICAL_SLUGS = frozenset({ + "minimax-m3", + "minimax/minimax-m3", +}) + + +def _is_minimax_m3(model: str | None) -> bool: + """Return True for canonical MiniMax-M3 model slugs only. + + MiniMax-M3 follows a distinct Anthropic-compatible thinking contract + (adaptive/disabled, no ``budget_tokens`` / ``output_config``) on MiniMax's + own Anthropic-compatible endpoints. Match the canonical slugs exactly so + future third-party slugs that merely contain the substring — e.g. + ``some-vendor/minimax-m3-preview`` — fall through to the existing manual + thinking branch. + """ + if not isinstance(model, str): + return False + normalized = model.strip().lower() + if not normalized: + return False + if "/" in normalized: + normalized = normalized.rsplit("/", 1)[-1] + return normalized in _MINIMAX_M3_CANONICAL_SLUGS + + def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: """Return True for MiniMax's Anthropic-compatible endpoints. @@ -2455,13 +2481,15 @@ def _manage_thinking_signatures( stripping, message merging) invalidates the signature, causing HTTP 400 "Invalid signature in thinking block". - Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax, - Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them - and will reject them outright. Kimi's /coding and DeepSeek's /anthropic - endpoints speak the Anthropic protocol upstream but require unsigned - thinking blocks (synthesised from ``reasoning_content``) to round-trip on - replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and - hermes-agent#16748 (DeepSeek). + Signatures are Anthropic-proprietary. Third-party endpoints (Azure AI + Foundry, AWS Bedrock, self-hosted proxies) cannot validate them and will + reject them outright. MiniMax-M3's Anthropic-compatible endpoints are a + documented exception: they require their complete thinking/text/tool_use + content blocks to be returned verbatim in later tool-call turns. Kimi's + /coding and DeepSeek's /anthropic endpoints also require unsigned thinking + blocks (synthesised from ``reasoning_content``) to round-trip on replayed + assistant tool-call messages. See MiniMax's Anthropic API compatibility + documentation, hermes-agent#13848 (Kimi), and hermes-agent#16748 (DeepSeek). Nous Portal's ``/v1/messages`` route is the exception among third-party hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the @@ -2490,7 +2518,24 @@ def _manage_thinking_signatures( if m.get("role") != "assistant" or not isinstance(m.get("content"), list): continue - if _is_kimi_family_endpoint(base_url, model): + is_minimax_m3 = _is_minimax_anthropic_endpoint(base_url) and _is_minimax_m3(model) + if is_minimax_m3: + # MiniMax-M3 requires complete content blocks (thinking, text, and + # tool_use) to round-trip unchanged across function-call turns. + # Its thinking blocks are not Anthropic signatures, so do not send + # this documented provider through the generic third-party strip. + # + # If orphan cleanup already removed a tool_use from this turn, the + # original content is no longer complete and must not be replayed + # as MiniMax thinking. Drop only the now-invalid thinking blocks; + # preserved text/tool_use still form a valid recovery turn. + if m.get("_thinking_signature_invalidated"): + m["content"] = [ + b + for b in m["content"] + if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES) + ] or [{"type": "text", "text": "(thinking elided)"}] + elif _is_kimi_family_endpoint(base_url, model): # Kimi does not enforce thinking signatures — replay as-is # (shared cleanup below still strips cache markers + the internal flag). pass @@ -2847,9 +2892,10 @@ def _to_oauth_wire_name(name: str) -> str: # Map reasoning_config to Anthropic's thinking parameter. # Claude 4.6+ models use adaptive thinking + output_config.effort. - # Older models use manual thinking with budget_tokens. - # MiniMax Anthropic-compat endpoints support thinking (manual mode only, - # not adaptive). Haiku does NOT support extended thinking — skip entirely. + # Older models use manual thinking with budget_tokens. MiniMax-M3 on its + # Anthropic-compatible endpoints uses a distinct adaptive contract with no + # output_config or budget_tokens; it returns separate thinking/text blocks. + # Haiku does NOT support extended thinking — skip entirely. # # Kimi / Moonshot models also use adaptive thinking: their # Anthropic-compatible endpoints (api.moonshot.cn/anthropic, @@ -2864,7 +2910,16 @@ def _to_oauth_wire_name(name: str) -> str: # request "summarized" so the reasoning blocks stay populated — matching # 4.6 behavior and preserving the activity-feed UX during long tool runs. if reasoning_config and isinstance(reasoning_config, dict): - if reasoning_config.get("enabled") is not False and "haiku" not in model.lower(): + is_minimax_m3 = _is_minimax_anthropic_endpoint(base_url) and _is_minimax_m3(model) + if is_minimax_m3: + # MiniMax documents only adaptive/disabled for M3. Do not send + # Anthropic's enabled + budget_tokens form: it is not MiniMax's + # structured-thinking contract. + if reasoning_config.get("enabled") is False: + kwargs["thinking"] = {"type": "disabled"} + else: + kwargs["thinking"] = {"type": "adaptive"} + elif reasoning_config.get("enabled") is not False and "haiku" not in model.lower(): effort = str(reasoning_config.get("effort", "medium")).lower() budget = THINKING_BUDGET.get(effort, 8000) if _supports_adaptive_thinking(model): diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 1152514c1e52..05c420b47170 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -93,12 +93,11 @@ def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch): class TestMinimaxThinkingSupport: - """Verify that MiniMax gets manual thinking (not adaptive). + """Verify MiniMax's model-specific Anthropic thinking contracts. - MiniMax's Anthropic-compat endpoint officially supports the thinking - parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api). - It should get manual thinking (type=enabled + budget_tokens), NOT adaptive - thinking (which is Claude 4.6-only). + MiniMax-M3 uses adaptive/disabled thinking on MiniMax's Anthropic-compatible + endpoints. M2.x keeps the legacy manual ``enabled + budget_tokens`` shape. + Source: https://platform.minimaxi.com/docs/api-reference/text-anthropic-api """ def test_minimax_m27_gets_manual_thinking(self): @@ -128,6 +127,408 @@ def test_minimax_m25_gets_manual_thinking(self): assert "thinking" in kwargs assert kwargs["thinking"]["type"] == "enabled" + def test_minimax_m3_cn_anthropic_uses_adaptive_thinking(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + def test_minimax_m3_effort_labels_all_collapse_to_adaptive(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + for effort in ("medium", "max", "ultra"): + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": effort}, + base_url="https://api.minimax.io/anthropic", + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + def test_minimax_m3_cn_anthropic_can_explicitly_disable_thinking(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": False}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"] == {"type": "disabled"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + def test_minimax_m3_like_slug_does_not_trigger_adaptive_thinking(self): + """Exact-match the canonical M3 slugs; do not over-match substring slugs.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3-preview", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"]["type"] == "enabled" + assert "budget_tokens" in kwargs["thinking"] + + def test_minimax_m3_raw_response_round_trips_all_blocks_in_order(self): + """Exercise raw SDK response -> normalization -> storage -> replay.""" + from types import SimpleNamespace + + from agent.anthropic_adapter import convert_messages_to_anthropic + from agent.chat_completion_helpers import build_assistant_message + from agent.transports import get_transport + + response = SimpleNamespace( + content=[ + SimpleNamespace( + type="thinking", + thinking="Inspect the file before answering.", + signature="minimax-sig-1", + ), + SimpleNamespace(type="text", text="I will inspect it."), + SimpleNamespace( + type="tool_use", + id="toolu_1", + name="read_file", + input={"path": "a.py"}, + ), + ], + stop_reason="tool_use", + usage=None, + ) + + class StubAgent: + verbose_logging = False + reasoning_callback = None + stream_delta_callback = None + _stream_callback = None + + def _extract_reasoning(self, message): + return getattr(message, "reasoning", None) + + def _strip_think_blocks(self, text): + return text + + def _needs_thinking_reasoning_pad(self): + return False + + def _split_responses_tool_id(self, raw_id): + return None, None + + def _derive_responses_function_call_id(self, call_id, response_item_id): + return response_item_id or call_id + + def _deterministic_call_id(self, name, arguments, index): + return f"generated_{index}" + + normalized = get_transport("anthropic_messages").normalize_response(response) + stored = build_assistant_message( + StubAgent(), normalized, normalized.finish_reason + ) + + assert [block["type"] for block in stored["anthropic_content_blocks"]] == [ + "thinking", + "text", + "tool_use", + ] + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Inspect a.py."}, + stored, + {"role": "tool", "tool_call_id": "toolu_1", "content": "ok"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == [ + "thinking", + "text", + "tool_use", + ] + assert assistant["content"][0]["signature"] == "minimax-sig-1" + assert assistant["content"][1]["text"] == "I will inspect it." + assert assistant["content"][2]["id"] == "toolu_1" + + def test_minimax_m3_accepts_prior_provider_reasoning_on_fallback(self): + """Document the current provider-agnostic history replay contract.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + tools = [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a value.", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[ + {"role": "user", "content": "Look up a value."}, + { + "role": "assistant", + "content": "", + "reasoning_content": "Prior-provider reasoning summary.", + "tool_calls": [ + { + "id": "call_prior", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_prior", "content": "value=42"}, + {"role": "user", "content": "What value was returned?"}, + ], + tools=tools, + max_tokens=1024, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assistant = next( + message for message in kwargs["messages"] if message["role"] == "assistant" + ) + assert [block["type"] for block in assistant["content"]] == [ + "thinking", + "tool_use", + ] + assert assistant["content"][1]["id"] == "call_prior" + + def test_minimax_m3_cn_replays_thinking_block_after_tool_call(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": "", + "reasoning_details": [ + {"type": "thinking", "thinking": "I should use the tool."} + ], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == ["thinking", "tool_use"] + assert assistant["content"][0]["thinking"] == "I should use the tool." + assert messages[-1]["content"][0]["type"] == "tool_result" + + def test_minimax_m3_drops_thinking_when_orphan_cleanup_mutates_tool_turn(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Call A and B."}, + {"type": "text", "text": "Will call A and B."}, + { + "type": "tool_use", + "id": "toolu_kept", + "name": "tool_a", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_orphan", + "name": "tool_b", + "input": {}, + }, + ], + "reasoning_details": [ + {"type": "thinking", "thinking": "Call A and B."} + ], + "tool_calls": [ + { + "id": "toolu_kept", + "type": "function", + "function": {"name": "tool_a", "arguments": "{}"}, + }, + { + "id": "toolu_orphan", + "type": "function", + "function": {"name": "tool_b", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "toolu_kept", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert not any(block.get("type") == "thinking" for block in assistant["content"]) + kept_tool_uses = [ + block["id"] for block in assistant["content"] if block.get("type") == "tool_use" + ] + # Pre-existing dual-source behavior appends tool_use blocks from both + # `content` and `tool_calls`; allow duplicates but require the kept id + # to be present and the orphan id to be absent. + assert "toolu_kept" in kept_tool_uses + assert "toolu_orphan" not in kept_tool_uses + # Surviving text block must be preserved alongside the kept tool_use. + text_blocks = [ + block for block in assistant["content"] if block.get("type") == "text" + ] + assert text_blocks and text_blocks[0]["text"] == "Will call A and B." + assert "Call A and B." not in str(assistant["content"]) + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_drops_thinking_when_all_tools_are_orphaned(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + { + "role": "assistant", + "content": "", + "reasoning_details": [ + {"type": "thinking", "thinking": "Call the tool."} + ], + "tool_calls": [ + { + "id": "toolu_orphan", + "type": "function", + "function": {"name": "tool_a", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "never mind"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert assistant["content"] == [{"type": "text", "text": "(thinking elided)"}] + assert "Call the tool." not in str(assistant["content"]) + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_replays_redacted_thinking_block(self): + """MiniMax-M3 must also preserve redacted_thinking across turns.""" + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": "", + "reasoning_details": [ + { + "type": "redacted_thinking", + "data": "redacted-payload-1", + } + ], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == [ + "redacted_thinking", + "tool_use", + ] + assert assistant["content"][0]["data"] == "redacted-payload-1" + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_orphan_flag_propagates_across_assistant_merge(self): + """An orphan flag on the second assistant must survive the merge.""" + from agent.anthropic_adapter import ( + _manage_thinking_signatures, + _merge_consecutive_roles, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Plan."}, + {"type": "text", "text": "First answer."}, + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Continuing..."}], + # Simulate the flag already set by orphan-tool stripping. + "_thinking_signature_invalidated": True, + }, + ] + + merged = _merge_consecutive_roles(msgs) + assert len(merged) == 1 + assert merged[0]["_thinking_signature_invalidated"] is True + + _manage_thinking_signatures( + merged, + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = merged[0] + assert "_thinking_signature_invalidated" not in assistant + assert not any( + block.get("type") == "thinking" for block in assistant["content"] + ) + assert [ + block["text"] for block in assistant["content"] if block.get("type") == "text" + ] == ["First answer.", "Continuing..."] + def test_thinking_still_works_for_claude(self): from agent.anthropic_adapter import build_anthropic_kwargs kwargs = build_anthropic_kwargs(