mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 13:49:15 +08:00
Compare commits
2 Commits
ethie/node
...
salvage/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e918627c | ||
|
|
2a34f5e241 |
@@ -1580,15 +1580,17 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
pool_present, entry = _select_pool_entry("openrouter")
|
||||
if pool_present:
|
||||
if pool_present and entry is not None:
|
||||
or_key = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
if not or_key:
|
||||
_mark_provider_unhealthy("openrouter", ttl=60)
|
||||
return None, None
|
||||
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
logger.debug("Auxiliary client: OpenRouter via pool")
|
||||
return OpenAI(api_key=or_key, base_url=base_url,
|
||||
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
|
||||
if or_key:
|
||||
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
logger.debug("Auxiliary client: OpenRouter via pool")
|
||||
return OpenAI(api_key=or_key, base_url=base_url,
|
||||
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
|
||||
# Pool entry present but no usable key — fall through to env var (#41035)
|
||||
elif pool_present:
|
||||
# Pool exists but no entry matched — fall through to env var (#41035)
|
||||
pass
|
||||
|
||||
or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
|
||||
if not or_key:
|
||||
@@ -4852,6 +4854,15 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
return "/anthropic" in url_lower
|
||||
|
||||
|
||||
def _is_openrouter_endpoint(base_url: str) -> bool:
|
||||
"""Detect if an endpoint is OpenRouter.
|
||||
|
||||
OpenRouter free/limited-credit tiers cannot afford the model's full
|
||||
output window and return HTTP 402 when max_tokens is omitted.
|
||||
"""
|
||||
return bool(base_url) and "openrouter" in base_url.lower()
|
||||
|
||||
|
||||
def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Convert OpenAI ``image_url``/``video_url`` blocks to Anthropic format.
|
||||
|
||||
@@ -4987,10 +4998,14 @@ def _build_call_kwargs(
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
#
|
||||
# OpenRouter is a second exception: free/limited-credit tiers cannot
|
||||
# afford the model's full output window and return HTTP 402 when
|
||||
# max_tokens is omitted. (#41035)
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base) or _is_openrouter_endpoint(_effective_base):
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -58,6 +58,7 @@ AUTHOR_MAP = {
|
||||
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"islam666@users.noreply.github.com": "islam666",
|
||||
"zhaolei.vc@bytedance.com": "zhaoleibd",
|
||||
"jeffrobodie@gmail.com": "jeffrobodie-glitch",
|
||||
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
|
||||
|
||||
@@ -106,7 +106,6 @@ class TestBuildCallKwargsMaxTokens:
|
||||
("copilot", "gpt-5.4", "https://api.githubcopilot.com"),
|
||||
("copilot", "gpt-5.5", "https://api.githubcopilot.com"),
|
||||
("custom", "gpt-5", "https://api.openai.com/v1"),
|
||||
("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1"),
|
||||
("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"),
|
||||
("custom", "qwen", "http://localhost:8080/v1"),
|
||||
("zai", "glm-4v-flash", "https://open.bigmodel.cn/api/paas/v4"),
|
||||
@@ -126,13 +125,18 @@ class TestBuildCallKwargsMaxTokens:
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,base_url",
|
||||
"provider,model,base_url,max_tokens_key",
|
||||
[
|
||||
("minimax", "minimax-m2", "https://api.minimax.io/v1"),
|
||||
("custom", "claude", "https://proxy.example.com/anthropic/v1"),
|
||||
# Anthropic wire: max_tokens is mandatory
|
||||
("minimax", "minimax-m2", "https://api.minimax.io/v1", "max_tokens"),
|
||||
("custom", "claude", "https://proxy.example.com/anthropic/v1", "max_tokens"),
|
||||
# OpenRouter: max_tokens required to avoid HTTP 402 on free tier (#41035)
|
||||
("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1", "max_tokens"),
|
||||
("openrouter", "openai/gpt-4o-mini", "https://openrouter.ai/api/v1", "max_tokens"),
|
||||
("custom", "some-model", "https://openrouter.ai/api/v1", "max_tokens"),
|
||||
],
|
||||
)
|
||||
def test_keeps_max_tokens_on_anthropic_wire(self, provider, model, base_url):
|
||||
def test_keeps_max_tokens_on_anthropic_wire(self, provider, model, base_url, max_tokens_key):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
@@ -142,7 +146,7 @@ class TestBuildCallKwargsMaxTokens:
|
||||
max_tokens=1234,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert kwargs["max_tokens"] == 1234
|
||||
assert kwargs[max_tokens_key] == 1234
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
|
||||
@@ -2131,7 +2135,99 @@ class TestAnthropicCompatImageConversion:
|
||||
assert _is_anthropic_compat_endpoint("custom", "https://example.com/anthropic/v1")
|
||||
assert not _is_anthropic_compat_endpoint("custom", "https://api.openai.com/v1")
|
||||
|
||||
def test_base64_image_converted(self):
|
||||
|
||||
class TestOpenrouterEndpointDetection:
|
||||
"""Tests for _is_openrouter_endpoint (#41035)."""
|
||||
|
||||
def test_openrouter_url_detected(self):
|
||||
from agent.auxiliary_client import _is_openrouter_endpoint
|
||||
assert _is_openrouter_endpoint("https://openrouter.ai/api/v1")
|
||||
assert _is_openrouter_endpoint("https://openrouter.ai/api/v1/")
|
||||
assert _is_openrouter_endpoint("https://subdomain.openrouter.ai/api/v1")
|
||||
|
||||
def test_non_openrouter_not_detected(self):
|
||||
from agent.auxiliary_client import _is_openrouter_endpoint
|
||||
assert not _is_openrouter_endpoint("https://api.openai.com/v1")
|
||||
assert not _is_openrouter_endpoint("https://api.minimax.io/v1")
|
||||
assert not _is_openrouter_endpoint("")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from agent.auxiliary_client import _is_openrouter_endpoint
|
||||
assert _is_openrouter_endpoint("https://OPENROUTER.AI/api/v1")
|
||||
assert _is_openrouter_endpoint("https://OpenRouter.ai/api/v1")
|
||||
|
||||
|
||||
class TestTryOpenrouterPoolFallback:
|
||||
"""Tests for _try_openrouter credential pool fallback to env var (#41035).
|
||||
|
||||
When a credential pool exists but has no usable entry, the function
|
||||
should fall through to the OPENROUTER_API_KEY env var instead of
|
||||
returning None, None.
|
||||
"""
|
||||
|
||||
def test_pool_no_entry_falls_through_to_env_var(self, monkeypatch):
|
||||
"""Pool present but entry is None → fall through to env var."""
|
||||
from agent.auxiliary_client import _try_openrouter
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test-key")
|
||||
|
||||
mock_entry = None
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, None)):
|
||||
with patch("agent.auxiliary_client.build_or_headers", return_value={}):
|
||||
client, model = _try_openrouter()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-test-key"
|
||||
|
||||
def test_pool_entry_no_key_falls_through_to_env_var(self, monkeypatch):
|
||||
"""Pool entry exists but has no runtime API key → fall through to env var."""
|
||||
from agent.auxiliary_client import _try_openrouter
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-env-key")
|
||||
|
||||
mock_entry = {"some_field": "value"}
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, mock_entry)):
|
||||
with patch("agent.auxiliary_client._pool_runtime_api_key", return_value=None):
|
||||
with patch("agent.auxiliary_client.build_or_headers", return_value={}):
|
||||
client, model = _try_openrouter()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-env-key"
|
||||
|
||||
def test_pool_entry_with_key_uses_pool(self):
|
||||
"""Pool entry with a valid key → use pool, not env var."""
|
||||
from agent.auxiliary_client import _try_openrouter
|
||||
|
||||
mock_entry = {"some_field": "value"}
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, mock_entry)):
|
||||
with patch("agent.auxiliary_client._pool_runtime_api_key", return_value="sk-pool-key"):
|
||||
with patch("agent.auxiliary_client._pool_runtime_base_url", return_value=None):
|
||||
with patch("agent.auxiliary_client.OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"):
|
||||
with patch("agent.auxiliary_client.build_or_headers", return_value={}):
|
||||
client, model = _try_openrouter()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-pool-key"
|
||||
|
||||
def test_no_pool_uses_env_var(self, monkeypatch):
|
||||
"""No pool present → use env var directly."""
|
||||
from agent.auxiliary_client import _try_openrouter
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-env-key")
|
||||
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
||||
with patch("agent.auxiliary_client.build_or_headers", return_value={}):
|
||||
client, model = _try_openrouter()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-env-key"
|
||||
|
||||
def test_no_pool_no_env_returns_none(self, monkeypatch):
|
||||
"""No pool and no env var → returns None, None."""
|
||||
from agent.auxiliary_client import _try_openrouter
|
||||
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
||||
client, model = _try_openrouter()
|
||||
assert client is None
|
||||
assert model is None
|
||||
from agent.auxiliary_client import _convert_openai_images_to_anthropic
|
||||
messages = [{
|
||||
"role": "user",
|
||||
|
||||
Reference in New Issue
Block a user