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
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.
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.
"""
if _anthropic_sdk is None:
@@ -92,7 +96,11 @@ def build_anthropic_client(api_key: str, base_url: str = None):
if 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.
# Anthropic routes OAuth requests based on user-agent and headers;
# without Claude Code's fingerprint, requests get intermittent 500s.
@@ -408,13 +416,17 @@ def normalize_model_name(model: str) -> str:
- Strips 'anthropic/' prefix (OpenRouter format, case-insensitive)
- Converts dots to hyphens in version numbers (OpenRouter uses dots,
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()
if lower.startswith("anthropic/"):
model = model[len("anthropic/"):]
# OpenRouter uses dots for version separators (claude-opus-4.6),
# Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.
model = model.replace(".", "-")
# Only convert dots to hyphens for Claude models — third-party models
# like MiniMax-M2.5 use dots as part of their canonical name.
if "claude" in model.lower():
model = model.replace(".", "-")
return model

View File

@@ -71,6 +71,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"MiniMax-M2.5": 204800,
"MiniMax-M2.5-highspeed": 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",
name="MiniMax",
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",),
base_url_env_var="MINIMAX_BASE_URL",
),
@@ -143,7 +143,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
id="minimax-cn",
name="MiniMax (China)",
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",),
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-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
],
"minimax-cn": [
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
],
"anthropic": [
"claude-opus-4-6",

View File

@@ -270,12 +270,15 @@ def resolve_runtime_provider(
}
# 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)
if pconfig and pconfig.auth_type == "api_key":
creds = resolve_api_key_provider_credentials(provider)
api_mode = "anthropic_messages" if provider in _ANTHROPIC_COMPAT_PROVIDERS else "chat_completions"
return {
"provider": provider,
"api_mode": "chat_completions",
"api_mode": api_mode,
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "env"),

View File

@@ -543,17 +543,30 @@ class AIAgent:
if self.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
effective_key = api_key or resolve_anthropic_token() or ""
# 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 ""
self._anthropic_api_key = effective_key
self._anthropic_base_url = base_url
from agent.anthropic_adapter import _is_oauth_token as _is_oat
self._is_anthropic_oauth = _is_oat(effective_key)
self._anthropic_client = build_anthropic_client(effective_key, 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
self._is_anthropic_oauth = _is_oat(effective_key)
self._anthropic_client = build_anthropic_client(
effective_key, base_url, third_party=self._is_third_party_anthropic,
)
# No OpenAI client needed for Anthropic mode
self.client = None
self._client_kwargs = {}
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:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
else:
@@ -2766,6 +2779,10 @@ class AIAgent:
def _try_refresh_anthropic_client_credentials(self) -> bool:
if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"):
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:
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
@@ -5370,23 +5387,28 @@ class AIAgent:
and not anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
if self._try_refresh_anthropic_client_credentials():
print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
continue
# Credential refresh didn't help — show diagnostic info
key = self._anthropic_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} Auth method: {auth_method}")
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:")
print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in ~/.hermes/.env for Hermes-managed OAuth/setup tokens")
print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in ~/.hermes/.env for API keys or legacy token values")
print(f"{self.log_prefix} • For API keys: verify at https://console.anthropic.com/settings/keys")
print(f"{self.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry")
print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{self.log_prefix} • Legacy cleanup: hermes config set 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)"
print(f"{self.log_prefix}🔐 Anthropic 401 — authentication failed.")
print(f"{self.log_prefix} Auth method: {auth_method}")
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:")
print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in ~/.hermes/.env for Hermes-managed OAuth/setup tokens")
print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in ~/.hermes/.env for API keys or legacy token values")
print(f"{self.log_prefix} • For API keys: verify at https://console.anthropic.com/settings/keys")
print(f"{self.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry")
print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{self.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_API_KEY \"\"")
retry_count += 1
elapsed_time = time.time() - api_start_time

View File

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

View File

@@ -68,8 +68,8 @@ class TestProviderRegistry:
def test_base_urls(self):
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["minimax"].inference_base_url == "https://api.minimax.io/v1"
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/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/anthropic"
def test_oauth_providers_unchanged(self):
"""Ensure we didn't break the existing OAuth providers."""
@@ -239,14 +239,14 @@ class TestResolveApiKeyProviderCredentials:
creds = resolve_api_key_provider_credentials("minimax")
assert creds["provider"] == "minimax"
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):
monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key")
creds = resolve_api_key_provider_credentials("minimax-cn")
assert creds["provider"] == "minimax-cn"
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):
monkeypatch.setenv("GLM_API_KEY", "glm-key")

View File

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