diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index d5c0c06fb..76761e262 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -95,6 +95,10 @@ _COMMON_BETAS = [ "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14", ] +# MiniMax's Anthropic-compatible endpoints fail tool-use requests when +# the fine-grained tool streaming beta is present. Omit it so tool calls +# fall back to the provider's default response path. +_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14" # Additional beta headers required for OAuth/subscription auth. # Matches what Claude Code (and pi-ai / OpenCode) send. @@ -204,6 +208,19 @@ def _requires_bearer_auth(base_url: str | None) -> bool: return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) +def _common_betas_for_base_url(base_url: str | None) -> list[str]: + """Return the beta headers that are safe for the configured endpoint. + + MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests + that include Anthropic's ``fine-grained-tool-streaming`` beta — every + tool-use message triggers a connection error. Strip that beta for + Bearer-auth endpoints while keeping all other betas intact. + """ + if _requires_bearer_auth(base_url): + return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA] + return _COMMON_BETAS + + def build_anthropic_client(api_key: str, base_url: str = None): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. @@ -222,6 +239,7 @@ def build_anthropic_client(api_key: str, base_url: str = None): } if normalized_base_url: kwargs["base_url"] = normalized_base_url + common_betas = _common_betas_for_base_url(normalized_base_url) if _requires_bearer_auth(normalized_base_url): # Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in @@ -231,21 +249,21 @@ def build_anthropic_client(api_key: str, base_url: str = None): # not use Anthropic's sk-ant-api prefix and would otherwise be misread as # Anthropic OAuth/setup tokens. kwargs["auth_token"] = api_key - if _COMMON_BETAS: - kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} elif _is_third_party_anthropic_endpoint(base_url): # Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their # own API keys with x-api-key auth. Skip OAuth detection — their keys # don't follow Anthropic's sk-ant-* prefix convention and would be # misclassified as OAuth tokens. kwargs["api_key"] = api_key - if _COMMON_BETAS: - kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} elif _is_oauth_token(api_key): # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; # without Claude Code's fingerprint, requests get intermittent 500s. - all_betas = _COMMON_BETAS + _OAUTH_ONLY_BETAS + all_betas = common_betas + _OAUTH_ONLY_BETAS kwargs["auth_token"] = api_key kwargs["default_headers"] = { "anthropic-beta": ",".join(all_betas), @@ -255,8 +273,8 @@ def build_anthropic_client(api_key: str, base_url: str = None): else: # Regular API key → x-api-key header + common betas kwargs["api_key"] = api_key - if _COMMON_BETAS: - kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} return _anthropic_sdk.Anthropic(**kwargs) @@ -1427,4 +1445,4 @@ def normalize_anthropic_response( reasoning_details=reasoning_details or None, ), finish_reason, - ) \ No newline at end of file + ) diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 0024fac62..6207b9e34 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -81,6 +81,9 @@ class TestBuildAnthropicClient: build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com") kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" + assert kwargs["default_headers"] == { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + } def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self): with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: @@ -92,7 +95,20 @@ class TestBuildAnthropicClient: assert kwargs["auth_token"] == "minimax-secret-123" assert "api_key" not in kwargs assert kwargs["default_headers"] == { - "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + "anthropic-beta": "interleaved-thinking-2025-05-14" + } + + def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "minimax-cn-secret-123", + base_url="https://api.minimaxi.com/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "minimax-cn-secret-123" + assert "api_key" not in kwargs + assert kwargs["default_headers"] == { + "anthropic-beta": "interleaved-thinking-2025-05-14" } diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index c6819e877..23bdcd476 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -1,4 +1,6 @@ -"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog.""" +"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog, beta headers.""" + +from unittest.mock import patch class TestMinimaxContextLengths: @@ -103,3 +105,100 @@ class TestMinimaxModelCatalog: models = _PROVIDER_MODELS[provider] assert "MiniMax-M2.7-highspeed" not in models assert "MiniMax-M2.5-highspeed" not in models + + +class TestMinimaxBetaHeaders: + """MiniMax Anthropic-compat endpoints reject fine-grained-tool-streaming beta. + + Verify that build_anthropic_client omits the tool-streaming beta for MiniMax + (both global and China domains) while keeping it for native Anthropic and + other third-party endpoints. Covers the fix for #6510 / #6555. + """ + + _TOOL_BETA = "fine-grained-tool-streaming-2025-05-14" + _THINKING_BETA = "interleaved-thinking-2025-05-14" + + # -- helper ---------------------------------------------------------- + + def _build_and_get_betas(self, api_key, base_url=None): + """Build client, return the anthropic-beta header string.""" + from agent.anthropic_adapter import build_anthropic_client + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client(api_key, base_url=base_url) + kwargs = mock_sdk.Anthropic.call_args[1] + headers = kwargs.get("default_headers", {}) + return headers.get("anthropic-beta", "") + + # -- MiniMax global -------------------------------------------------- + + def test_minimax_global_omits_tool_streaming(self): + betas = self._build_and_get_betas( + "mm-key-123", base_url="https://api.minimax.io/anthropic" + ) + assert self._TOOL_BETA not in betas + assert self._THINKING_BETA in betas + + def test_minimax_global_trailing_slash(self): + betas = self._build_and_get_betas( + "mm-key-123", base_url="https://api.minimax.io/anthropic/" + ) + assert self._TOOL_BETA not in betas + + # -- MiniMax China --------------------------------------------------- + + def test_minimax_cn_omits_tool_streaming(self): + betas = self._build_and_get_betas( + "mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic" + ) + assert self._TOOL_BETA not in betas + assert self._THINKING_BETA in betas + + def test_minimax_cn_trailing_slash(self): + betas = self._build_and_get_betas( + "mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic/" + ) + assert self._TOOL_BETA not in betas + + # -- Non-MiniMax keeps full betas ------------------------------------ + + def test_native_anthropic_keeps_tool_streaming(self): + betas = self._build_and_get_betas("sk-ant-api03-real-key-here") + assert self._TOOL_BETA in betas + assert self._THINKING_BETA in betas + + def test_third_party_proxy_keeps_tool_streaming(self): + betas = self._build_and_get_betas( + "custom-key", base_url="https://my-proxy.example.com/anthropic" + ) + assert self._TOOL_BETA in betas + + def test_custom_base_url_keeps_tool_streaming(self): + betas = self._build_and_get_betas( + "custom-key", base_url="https://custom.api.com" + ) + assert self._TOOL_BETA in betas + + # -- _common_betas_for_base_url unit tests --------------------------- + + def test_common_betas_none_url(self): + from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + assert _common_betas_for_base_url(None) == _COMMON_BETAS + + def test_common_betas_empty_url(self): + from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + assert _common_betas_for_base_url("") == _COMMON_BETAS + + def test_common_betas_minimax_url(self): + from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA + betas = _common_betas_for_base_url("https://api.minimax.io/anthropic") + assert _TOOL_STREAMING_BETA not in betas + assert len(betas) > 0 # still has other betas + + def test_common_betas_minimax_cn_url(self): + from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA + betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic") + assert _TOOL_STREAMING_BETA not in betas + + def test_common_betas_regular_url(self): + from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS