mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
3 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea53b8eed | ||
|
|
496ed0e78b | ||
|
|
3304bc93a4 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
28
run_agent.py
28
run_agent.py
@@ -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}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user