From 08fe37aee1512d9a8d6bb223e247be5f4e0d3137 Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Mon, 13 Apr 2026 23:46:02 +0000 Subject: [PATCH] feat: implement Hermes adapter create_executor() with OpenRouter fallback Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/hermes/__init__.py | 3 + workspace-template/adapters/hermes/adapter.py | 42 ++++- .../adapters/hermes/executor.py | 153 ++++++++++++++++++ workspace-template/tests/test_hermes_smoke.py | 84 ++++++++++ 4 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 workspace-template/adapters/hermes/executor.py create mode 100644 workspace-template/tests/test_hermes_smoke.py diff --git a/workspace-template/adapters/hermes/__init__.py b/workspace-template/adapters/hermes/__init__.py index 40dcf46d..37dc9a89 100644 --- a/workspace-template/adapters/hermes/__init__.py +++ b/workspace-template/adapters/hermes/__init__.py @@ -1,3 +1,6 @@ from .adapter import HermesAdapter +from .executor import create_executor Adapter = HermesAdapter + +__all__ = ["create_executor", "HermesAdapter", "Adapter"] diff --git a/workspace-template/adapters/hermes/adapter.py b/workspace-template/adapters/hermes/adapter.py index 91d7a271..0e6526f2 100644 --- a/workspace-template/adapters/hermes/adapter.py +++ b/workspace-template/adapters/hermes/adapter.py @@ -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 diff --git a/workspace-template/adapters/hermes/executor.py b/workspace-template/adapters/hermes/executor.py new file mode 100644 index 00000000..ac7ae5c1 --- /dev/null +++ b/workspace-template/adapters/hermes/executor.py @@ -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 diff --git a/workspace-template/tests/test_hermes_smoke.py b/workspace-template/tests/test_hermes_smoke.py new file mode 100644 index 00000000..fed10a92 --- /dev/null +++ b/workspace-template/tests/test_hermes_smoke.py @@ -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