Compare commits

...

3 Commits

Author SHA1 Message Date
teknium1
3ea53b8eed feat: switch MiniMax to Anthropic Messages API for reliable tool calling
MiniMax's OpenAI-compatible endpoint has a known issue where tool calls
are sometimes returned as XML content (<minimax:tool_call>) instead of
structured tool_calls. Their Anthropic Messages API endpoint handles
this correctly.

Changes:
- Switch MiniMax/MiniMax-CN base URLs to Anthropic endpoints
  (api.minimax.io/anthropic, api.minimaxi.com/anthropic)
- Return api_mode='anthropic_messages' for MiniMax providers
- Add third_party flag to build_anthropic_client() to skip OAuth
  detection, Anthropic beta headers, and Claude Code user-agent
- Guard normalize_model_name() to not mangle non-Claude model names
  (MiniMax-M2.5 would incorrectly become MiniMax-M2-5)
- Skip Anthropic credential refresh for third-party providers
- Show provider-appropriate 401 diagnostics for third-party providers
- Add missing MiniMax models (M2.1-highspeed, M2) to metadata/catalog
- Update tests for new URLs and third_party kwarg

MiniMax's Anthropic compat layer supports:
- Tool calling (text, tool_use, tool_result)
- Thinking/reasoning (interleaved thinking)
- Explicit prompt caching (cache_control with 5min TTL)
- Streaming
- 204,800 token context for all M2/M2.1/M2.5 models

Ref: https://platform.minimax.io/docs/api-reference/text-anthropic-api
2026-03-17 00:00:31 -07:00
teknium1
496ed0e78b fix: harden execute_code cleanup and reduce logging noise
Follow-up to cherry-picked PR #1588 (aydnOktay):
- Initialize server_sock = None before try block to prevent NameError
  if exception occurs before socket creation (line 413 is inside the try)
- Guard server_sock.close() with None check
- Narrow cleanup exception handlers to OSError (the actual error type)
- Remove exc_info=True from cleanup debug logs — benign teardown
  failures don't need stack traces, the message is sufficient
- Remove redundant try/except around shutil.rmtree(ignore_errors=True)
- Silence sock_path unlink with pass — expected when already cleaned up
2026-03-16 23:12:18 -07:00
aydnOktay
3304bc93a4 fix(tools): improve error logging in code_execution_tool 2026-03-16 23:06:21 -07:00
9 changed files with 91 additions and 42 deletions

View File

@@ -74,9 +74,13 @@ def _is_oauth_token(key: str) -> bool:
return True return True
def build_anthropic_client(api_key: str, base_url: str = None): def build_anthropic_client(api_key: str, base_url: str = None, third_party: bool = False):
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys. """Create an Anthropic client, auto-detecting setup-tokens vs API keys.
When *third_party* is True (e.g. MiniMax's Anthropic-compatible endpoint),
skips OAuth detection, beta headers, and Claude Code user-agent — uses
plain api_key auth only.
Returns an anthropic.Anthropic instance. Returns an anthropic.Anthropic instance.
""" """
if _anthropic_sdk is None: if _anthropic_sdk is None:
@@ -92,7 +96,11 @@ def build_anthropic_client(api_key: str, base_url: str = None):
if base_url: if base_url:
kwargs["base_url"] = base_url kwargs["base_url"] = base_url
if _is_oauth_token(api_key): if third_party:
# Third-party Anthropic-compatible endpoint (e.g. MiniMax) —
# plain api_key, no Anthropic-specific betas or Claude Code headers.
kwargs["api_key"] = api_key
elif _is_oauth_token(api_key):
# OAuth access token / setup-token → Bearer auth + Claude Code identity. # OAuth access token / setup-token → Bearer auth + Claude Code identity.
# Anthropic routes OAuth requests based on user-agent and headers; # Anthropic routes OAuth requests based on user-agent and headers;
# without Claude Code's fingerprint, requests get intermittent 500s. # without Claude Code's fingerprint, requests get intermittent 500s.
@@ -408,12 +416,16 @@ def normalize_model_name(model: str) -> str:
- Strips 'anthropic/' prefix (OpenRouter format, case-insensitive) - Strips 'anthropic/' prefix (OpenRouter format, case-insensitive)
- Converts dots to hyphens in version numbers (OpenRouter uses dots, - Converts dots to hyphens in version numbers (OpenRouter uses dots,
Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6) Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6)
Only applies transforms to Claude models. Third-party models (e.g.
MiniMax-M2.5) are returned as-is to preserve their naming convention.
""" """
lower = model.lower() lower = model.lower()
if lower.startswith("anthropic/"): if lower.startswith("anthropic/"):
model = model[len("anthropic/"):] model = model[len("anthropic/"):]
# OpenRouter uses dots for version separators (claude-opus-4.6), # Only convert dots to hyphens for Claude models — third-party models
# Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens. # like MiniMax-M2.5 use dots as part of their canonical name.
if "claude" in model.lower():
model = model.replace(".", "-") model = model.replace(".", "-")
return model return model

View File

@@ -71,6 +71,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"MiniMax-M2.5": 204800, "MiniMax-M2.5": 204800,
"MiniMax-M2.5-highspeed": 204800, "MiniMax-M2.5-highspeed": 204800,
"MiniMax-M2.1": 204800, "MiniMax-M2.1": 204800,
"MiniMax-M2.1-highspeed": 204800,
"MiniMax-M2": 204800,
} }

View File

@@ -128,7 +128,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
id="minimax", id="minimax",
name="MiniMax", name="MiniMax",
auth_type="api_key", auth_type="api_key",
inference_base_url="https://api.minimax.io/v1", inference_base_url="https://api.minimax.io/anthropic",
api_key_env_vars=("MINIMAX_API_KEY",), api_key_env_vars=("MINIMAX_API_KEY",),
base_url_env_var="MINIMAX_BASE_URL", base_url_env_var="MINIMAX_BASE_URL",
), ),
@@ -143,7 +143,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
id="minimax-cn", id="minimax-cn",
name="MiniMax (China)", name="MiniMax (China)",
auth_type="api_key", auth_type="api_key",
inference_base_url="https://api.minimaxi.com/v1", inference_base_url="https://api.minimaxi.com/anthropic",
api_key_env_vars=("MINIMAX_CN_API_KEY",), api_key_env_vars=("MINIMAX_CN_API_KEY",),
base_url_env_var="MINIMAX_CN_BASE_URL", base_url_env_var="MINIMAX_CN_BASE_URL",
), ),

View File

@@ -63,11 +63,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2.5", "MiniMax-M2.5",
"MiniMax-M2.5-highspeed", "MiniMax-M2.5-highspeed",
"MiniMax-M2.1", "MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
], ],
"minimax-cn": [ "minimax-cn": [
"MiniMax-M2.5", "MiniMax-M2.5",
"MiniMax-M2.5-highspeed", "MiniMax-M2.5-highspeed",
"MiniMax-M2.1", "MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
], ],
"anthropic": [ "anthropic": [
"claude-opus-4-6", "claude-opus-4-6",

View File

@@ -270,12 +270,15 @@ def resolve_runtime_provider(
} }
# API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN)
# MiniMax uses Anthropic Messages API format for reliable tool calling.
_ANTHROPIC_COMPAT_PROVIDERS = {"minimax", "minimax-cn"}
pconfig = PROVIDER_REGISTRY.get(provider) pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig and pconfig.auth_type == "api_key": if pconfig and pconfig.auth_type == "api_key":
creds = resolve_api_key_provider_credentials(provider) creds = resolve_api_key_provider_credentials(provider)
api_mode = "anthropic_messages" if provider in _ANTHROPIC_COMPAT_PROVIDERS else "chat_completions"
return { return {
"provider": provider, "provider": provider,
"api_mode": "chat_completions", "api_mode": api_mode,
"base_url": creds.get("base_url", "").rstrip("/"), "base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""), "api_key": creds.get("api_key", ""),
"source": creds.get("source", "env"), "source": creds.get("source", "env"),

View File

@@ -543,17 +543,30 @@ class AIAgent:
if self.api_mode == "anthropic_messages": if self.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
# Third-party Anthropic-compatible providers (e.g. MiniMax) use
# plain api_key auth — no OAuth detection or Anthropic env vars.
_THIRD_PARTY_ANTHROPIC = {"minimax", "minimax-cn"}
self._is_third_party_anthropic = self.provider in _THIRD_PARTY_ANTHROPIC
if self._is_third_party_anthropic:
effective_key = api_key or ""
else:
effective_key = api_key or resolve_anthropic_token() or "" effective_key = api_key or resolve_anthropic_token() or ""
self._anthropic_api_key = effective_key self._anthropic_api_key = effective_key
self._anthropic_base_url = base_url self._anthropic_base_url = base_url
if self._is_third_party_anthropic:
self._is_anthropic_oauth = False
else:
from agent.anthropic_adapter import _is_oauth_token as _is_oat from agent.anthropic_adapter import _is_oauth_token as _is_oat
self._is_anthropic_oauth = _is_oat(effective_key) self._is_anthropic_oauth = _is_oat(effective_key)
self._anthropic_client = build_anthropic_client(effective_key, base_url) self._anthropic_client = build_anthropic_client(
effective_key, base_url, third_party=self._is_third_party_anthropic,
)
# No OpenAI client needed for Anthropic mode # No OpenAI client needed for Anthropic mode
self.client = None self.client = None
self._client_kwargs = {} self._client_kwargs = {}
if not self.quiet_mode: if not self.quiet_mode:
print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") label = f"{self.provider} (Anthropic compat)" if self._is_third_party_anthropic else "Anthropic native"
print(f"🤖 AI Agent initialized with model: {self.model} ({label})")
if effective_key and len(effective_key) > 12: if effective_key and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
else: else:
@@ -2766,6 +2779,10 @@ class AIAgent:
def _try_refresh_anthropic_client_credentials(self) -> bool: def _try_refresh_anthropic_client_credentials(self) -> bool:
if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"):
return False return False
# Third-party Anthropic-compatible providers (e.g. MiniMax) manage
# their own credentials — don't try to resolve Anthropic tokens.
if getattr(self, "_is_third_party_anthropic", False):
return False
try: try:
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
@@ -5370,12 +5387,17 @@ class AIAgent:
and not anthropic_auth_retry_attempted and not anthropic_auth_retry_attempted
): ):
anthropic_auth_retry_attempted = True anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
if self._try_refresh_anthropic_client_credentials(): if self._try_refresh_anthropic_client_credentials():
print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...") print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
continue continue
# Credential refresh didn't help — show diagnostic info # Credential refresh didn't help — show diagnostic info
key = self._anthropic_api_key key = self._anthropic_api_key
if getattr(self, "_is_third_party_anthropic", False):
print(f"{self.log_prefix}🔐 {self.provider} 401 — authentication failed.")
print(f"{self.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{self.log_prefix} Token: (empty or short)")
print(f"{self.log_prefix} Troubleshooting: check your API key in ~/.hermes/.env")
else:
from agent.anthropic_adapter import _is_oauth_token
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
print(f"{self.log_prefix}🔐 Anthropic 401 — authentication failed.") print(f"{self.log_prefix}🔐 Anthropic 401 — authentication failed.")
print(f"{self.log_prefix} Auth method: {auth_method}") print(f"{self.log_prefix} Auth method: {auth_method}")

View File

@@ -107,7 +107,7 @@ class _FakeAnthropicClient:
pass pass
def _fake_build_anthropic_client(key, base_url=None): def _fake_build_anthropic_client(key, base_url=None, **kwargs):
return _FakeAnthropicClient() return _FakeAnthropicClient()

View File

@@ -68,8 +68,8 @@ class TestProviderRegistry:
def test_base_urls(self): def test_base_urls(self):
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1" assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1" assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
def test_oauth_providers_unchanged(self): def test_oauth_providers_unchanged(self):
"""Ensure we didn't break the existing OAuth providers.""" """Ensure we didn't break the existing OAuth providers."""
@@ -239,14 +239,14 @@ class TestResolveApiKeyProviderCredentials:
creds = resolve_api_key_provider_credentials("minimax") creds = resolve_api_key_provider_credentials("minimax")
assert creds["provider"] == "minimax" assert creds["provider"] == "minimax"
assert creds["api_key"] == "mm-secret-key" assert creds["api_key"] == "mm-secret-key"
assert creds["base_url"] == "https://api.minimax.io/v1" assert creds["base_url"] == "https://api.minimax.io/anthropic"
def test_resolve_minimax_cn_with_key(self, monkeypatch): def test_resolve_minimax_cn_with_key(self, monkeypatch):
monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key") monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key")
creds = resolve_api_key_provider_credentials("minimax-cn") creds = resolve_api_key_provider_credentials("minimax-cn")
assert creds["provider"] == "minimax-cn" assert creds["provider"] == "minimax-cn"
assert creds["api_key"] == "mmcn-secret-key" assert creds["api_key"] == "mmcn-secret-key"
assert creds["base_url"] == "https://api.minimaxi.com/v1" assert creds["base_url"] == "https://api.minimaxi.com/anthropic"
def test_resolve_with_custom_base_url(self, monkeypatch): def test_resolve_with_custom_base_url(self, monkeypatch):
monkeypatch.setenv("GLM_API_KEY", "glm-key") monkeypatch.setenv("GLM_API_KEY", "glm-key")

View File

@@ -395,6 +395,7 @@ def execute_code(
tool_call_log: list = [] tool_call_log: list = []
tool_call_counter = [0] # mutable so the RPC thread can increment tool_call_counter = [0] # mutable so the RPC thread can increment
exec_start = time.monotonic() exec_start = time.monotonic()
server_sock = None
try: try:
# Write the auto-generated hermes_tools module # Write the auto-generated hermes_tools module
@@ -598,7 +599,14 @@ def execute_code(
except Exception as exc: except Exception as exc:
duration = round(time.monotonic() - exec_start, 2) duration = round(time.monotonic() - exec_start, 2)
logging.exception("execute_code failed") logger.error(
"execute_code failed after %ss with %d tool calls: %s: %s",
duration,
tool_call_counter[0],
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({ return json.dumps({
"status": "error", "status": "error",
"error": str(exc), "error": str(exc),
@@ -608,19 +616,17 @@ def execute_code(
finally: finally:
# Cleanup temp dir and socket # Cleanup temp dir and socket
if server_sock is not None:
try: try:
server_sock.close() server_sock.close()
except Exception as e: except OSError as e:
logger.debug("Server socket close error: %s", e) logger.debug("Server socket close error: %s", e)
try:
import shutil import shutil
shutil.rmtree(tmpdir, ignore_errors=True) shutil.rmtree(tmpdir, ignore_errors=True)
except Exception as e:
logger.debug("Could not clean temp dir: %s", e, exc_info=True)
try: try:
os.unlink(sock_path) os.unlink(sock_path)
except OSError as e: except OSError:
logger.debug("Could not remove socket file: %s", e, exc_info=True) pass # already cleaned up or never created
def _kill_process_group(proc, escalate: bool = False): def _kill_process_group(proc, escalate: bool = False):