feat: implement Hermes adapter create_executor() with OpenRouter fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Lead Agent 2026-04-13 23:46:02 +00:00 committed by rabbitblood
parent 3e1e46faa5
commit 791def3fdf
4 changed files with 275 additions and 7 deletions

View File

@ -1,3 +1,6 @@
from .adapter import HermesAdapter
from .executor import create_executor
Adapter = HermesAdapter
__all__ = ["create_executor", "HermesAdapter", "Adapter"]

View File

@ -1,9 +1,11 @@
"""Hermes adapter stub — full executor implementation ships in PR 2.
"""Hermes adapter — Nous Research Hermes models via Nous Portal or OpenRouter.
PR 1 shell: registers 'hermes' with discover_adapters() so the adapter
catalogue is complete. setup() validates the openai dep is present.
create_executor() raises NotImplementedError until PR 2 lands.
Uses the OpenAI-compatible client (openai>=1.0.0) to communicate with
either the Nous Portal directly (HERMES_API_KEY) or OpenRouter as a
fallback (OPENROUTER_API_KEY).
"""
import os
from adapters.base import BaseAdapter, AdapterConfig
@ -21,6 +23,18 @@ class HermesAdapter(BaseAdapter):
def description() -> str:
return "Hermes models via Nous Portal or OpenRouter — openai>=1.0.0 compatible client"
@staticmethod
def get_config_schema() -> dict:
return {
"model": {
"type": "string",
"description": (
"Hermes model ID (e.g. nousresearch/hermes-3-llama-3.1-405b for OpenRouter "
"or hermes-3-llama-3.1-405b for Nous Portal)"
),
},
}
async def setup(self, config: AdapterConfig) -> None: # pragma: no cover
try:
import openai # noqa: F401
@ -31,6 +45,20 @@ class HermesAdapter(BaseAdapter):
) from e
async def create_executor(self, config: AdapterConfig): # pragma: no cover
raise NotImplementedError(
"HermesAdapter.create_executor not yet implemented — ships in PR 2"
)
"""Create and return a HermesA2AExecutor using key resolution from env/config."""
from .executor import create_executor, HermesA2AExecutor
# Resolve API key: prefer workspace secrets (runtime_config), then env vars
hermes_api_key = config.runtime_config.get("hermes_api_key") or None
executor = create_executor(hermes_api_key=hermes_api_key)
# Override model from config if provided
model = config.model
if ":" in model:
_, model = model.split(":", 1)
if model:
executor.model = model
executor._heartbeat = config.heartbeat
return executor

View File

@ -0,0 +1,153 @@
"""Hermes adapter executor — implements create_executor() for PR 2.
Hermes models (Nous Research) are accessed via an OpenAI-compatible API,
either through the Nous Portal directly or via OpenRouter as a fallback.
Key resolution order
--------------------
1. ``hermes_api_key`` parameter (explicit call-site override)
2. ``HERMES_API_KEY`` environment variable (Nous Portal key)
3. ``OPENROUTER_API_KEY`` environment variable (OpenRouter fallback)
Raises ``ValueError`` if none of the three sources yields a non-empty key.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
# Default base URLs
_NOUS_BASE_URL = "https://inference-prod.nousresearch.com/v1"
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Default model when routing through OpenRouter
_DEFAULT_MODEL = "nousresearch/hermes-3-llama-3.1-405b"
def create_executor(hermes_api_key: str | None = None):
"""Create and return a LangGraph-compatible executor for the Hermes adapter.
Key resolution order:
1. hermes_api_key parameter (if provided)
2. HERMES_API_KEY environment variable
3. OPENROUTER_API_KEY environment variable (fallback)
Raises ValueError if none of the above are found.
Parameters
----------
hermes_api_key:
Explicit API key. When provided, the Nous Portal base URL is used.
When absent and OPENROUTER_API_KEY is the fallback, OpenRouter's
base URL is used instead.
Returns
-------
HermesA2AExecutor
A ready-to-use executor instance wired with the resolved key
and matching base URL.
"""
api_key: str | None = None
base_url: str = _NOUS_BASE_URL
if hermes_api_key:
api_key = hermes_api_key
base_url = _NOUS_BASE_URL
logger.debug("Hermes: using explicit hermes_api_key param")
else:
env_hermes = os.environ.get("HERMES_API_KEY", "").strip()
if env_hermes:
api_key = env_hermes
base_url = _NOUS_BASE_URL
logger.debug("Hermes: using HERMES_API_KEY env var")
else:
env_openrouter = os.environ.get("OPENROUTER_API_KEY", "").strip()
if env_openrouter:
api_key = env_openrouter
base_url = _OPENROUTER_BASE_URL
logger.debug("Hermes: using OPENROUTER_API_KEY env var (fallback)")
if not api_key:
raise ValueError(
"No API key found: provide hermes_api_key param, "
"or set HERMES_API_KEY or OPENROUTER_API_KEY env var"
)
return HermesA2AExecutor(api_key=api_key, base_url=base_url)
class HermesA2AExecutor:
"""LangGraph-compatible AgentExecutor for Hermes models.
Uses the OpenAI-compatible ``openai`` client pointed at either the
Nous Portal or OpenRouter, matching the pattern of sibling adapters
(AutoGen, LangGraph) which all use OpenAI-compatible clients.
The ``execute()`` and ``cancel()`` async methods satisfy the
``a2a.server.agent_execution.AgentExecutor`` interface so this
executor can be dropped into the A2A server's DefaultRequestHandler.
"""
def __init__(
self,
api_key: str,
base_url: str = _NOUS_BASE_URL,
model: str = _DEFAULT_MODEL,
heartbeat=None,
):
self.api_key = api_key
self.base_url = base_url
self.model = model
self._heartbeat = heartbeat
# ------------------------------------------------------------------
# AgentExecutor interface
# ------------------------------------------------------------------
async def execute(self, context, event_queue): # pragma: no cover
"""Execute a Hermes inference request and push the reply to event_queue."""
from a2a.utils import new_agent_text_message
from adapters.shared_runtime import (
brief_task,
build_task_text,
extract_history,
extract_message_text,
set_current_task,
)
user_message = extract_message_text(context)
if not user_message:
await event_queue.enqueue_event(new_agent_text_message("No message provided"))
return
await set_current_task(self._heartbeat, brief_task(user_message))
try:
import openai
client = openai.AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
task_text = build_task_text(user_message, extract_history(context))
response = await client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": task_text}],
)
reply = response.choices[0].message.content or ""
except Exception as exc:
logger.exception("Hermes executor error: %s", exc)
reply = f"Hermes error: {exc}"
finally:
await set_current_task(self._heartbeat, "")
await event_queue.enqueue_event(new_agent_text_message(reply))
async def cancel(self, context, event_queue): # pragma: no cover
"""No-op cancel — Hermes requests are not cancellable mid-flight."""
pass

View File

@ -0,0 +1,84 @@
"""Smoke tests for adapters.hermes.create_executor().
Verifies key resolution order and ValueError on missing keys.
No real network calls are made the executor object is just instantiated.
"""
import os
import pytest
from unittest.mock import patch
from adapters.hermes import create_executor
def test_create_executor_with_param():
"""create_executor() works when key passed directly as param."""
executor = create_executor(hermes_api_key="test-key-direct")
assert executor is not None
def test_create_executor_with_hermes_env():
"""create_executor() works when HERMES_API_KEY env var is set."""
with patch.dict(os.environ, {"HERMES_API_KEY": "test-hermes-key"}, clear=False):
os.environ.pop("OPENROUTER_API_KEY", None)
executor = create_executor()
assert executor is not None
def test_create_executor_falls_back_to_openrouter():
"""create_executor() falls back to OPENROUTER_API_KEY when HERMES_API_KEY absent."""
env = {"OPENROUTER_API_KEY": "test-openrouter-key"}
with patch.dict(os.environ, env, clear=False):
os.environ.pop("HERMES_API_KEY", None)
executor = create_executor()
assert executor is not None
def test_create_executor_raises_without_keys():
"""create_executor() raises ValueError when no keys available."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_API_KEY", None)
os.environ.pop("OPENROUTER_API_KEY", None)
with pytest.raises(ValueError):
create_executor()
# ---------------------------------------------------------------------------
# Additional assertions — verify key routing is correct
# ---------------------------------------------------------------------------
def test_param_key_uses_nous_base_url():
"""When called with explicit key, base_url points at Nous Portal."""
executor = create_executor(hermes_api_key="nous-key")
assert "nousresearch.com" in executor.base_url
def test_hermes_env_uses_nous_base_url():
"""HERMES_API_KEY maps to Nous Portal base URL."""
with patch.dict(os.environ, {"HERMES_API_KEY": "nous-key"}, clear=False):
os.environ.pop("OPENROUTER_API_KEY", None)
executor = create_executor()
assert "nousresearch.com" in executor.base_url
def test_openrouter_fallback_uses_openrouter_base_url():
"""OPENROUTER_API_KEY fallback maps to OpenRouter base URL."""
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "or-key"}, clear=False):
os.environ.pop("HERMES_API_KEY", None)
executor = create_executor()
assert "openrouter.ai" in executor.base_url
def test_param_takes_priority_over_hermes_env():
"""Explicit param overrides HERMES_API_KEY env var."""
with patch.dict(os.environ, {"HERMES_API_KEY": "env-key"}, clear=False):
executor = create_executor(hermes_api_key="param-key")
assert executor.api_key == "param-key"
def test_hermes_env_takes_priority_over_openrouter():
"""HERMES_API_KEY overrides OPENROUTER_API_KEY fallback."""
env = {"HERMES_API_KEY": "hermes-key", "OPENROUTER_API_KEY": "or-key"}
with patch.dict(os.environ, env, clear=False):
executor = create_executor()
assert executor.api_key == "hermes-key"
assert "nousresearch.com" in executor.base_url