molecule-core/workspace-template/tests/test_hermes_providers.py
rabbitblood 8d8ca18bc0 feat(hermes): Phase 1 — multi-provider registry (15 providers, back-compat preserved)
Ships the first half of the queued Hermes adapter expansion. PR 2 only
supported Nous Portal + OpenRouter; this adds 13 more providers reachable
via OpenAI-compat endpoints. Native SDK paths for Anthropic + Gemini are
Phase 2 (better tool-calling + vision fidelity).

## What's new

**`workspace-template/adapters/hermes/providers.py`** (new file, 220 LOC):
- ``ProviderConfig`` dataclass: name, env vars, base URL, default model, auth scheme, docs
- ``PROVIDERS`` dict with 15 entries across 4 groups:
  - PR 2 baseline: nous_portal, openrouter
  - Frontier commercial: openai, anthropic, xai, gemini
  - Chinese providers: qwen, glm, kimi, minimax, deepseek
  - OSS/alt: groq, together, fireworks, mistral
- ``RESOLUTION_ORDER`` tuple: priority for auto-detect (back-compat first,
  then commercial, then Chinese, then OSS/alt)
- ``resolve_provider(explicit=None)`` -> (ProviderConfig, api_key)
  - With explicit name: routes to that provider, raises if env var empty
  - Without: walks RESOLUTION_ORDER, first env-var-set provider wins

**`workspace-template/adapters/hermes/executor.py`** (refactored):
- `create_executor(hermes_api_key=None, provider=None, model=None)` now has
  three parameters:
  - `hermes_api_key`: PR 2 back-compat — routes to Nous Portal
  - `provider`: canonical short name from the registry (e.g. "anthropic")
  - `model`: optional override of the provider's default model
- Delegates all resolution to `providers.resolve_provider()` — no more
  hardcoded URLs or env var lookups in the executor itself
- `HermesA2AExecutor.__init__` no longer has Nous-specific defaults; callers
  pass base_url + model explicitly (which create_executor always does)

**`workspace-template/tests/test_hermes_providers.py`** (new file, 26 tests):
- Registry shape invariants (count >= 15, no duplicates, every config valid)
- PR 2 back-compat: HERMES_API_KEY / OPENROUTER_API_KEY still route correctly
- Auto-detect for every provider in the registry (parametrized — guards against
  typos in env var lists)
- Explicit `provider=` bypass of auto-detect
- Error cases: unknown provider, explicit-but-empty, auto-detect-with-no-env
- All 26 tests pass locally in 0.08s

## Back-compat guarantees

| Scenario | PR 2 behavior | This PR behavior |
|---|---|---|
| `create_executor(hermes_api_key="x")` | Nous Portal | Nous Portal (unchanged) |
| `HERMES_API_KEY=x` env, auto-detect | Nous Portal | Nous Portal (unchanged) |
| `OPENROUTER_API_KEY=x` env, auto-detect | OpenRouter | OpenRouter (unchanged) |
| Both env + explicit hermes_api_key param | Nous Portal (param wins) | Nous Portal (param wins, unchanged) |

Nothing existing can break. New callers gain access to 13 more providers.

## What's NOT in this PR (Phase 2)

- **Native Anthropic Messages API path** — better tool calling, vision, extended
  thinking. Requires pulling in `anthropic` SDK. ~50 LOC.
- **Native Gemini generateContent path** — for vision + google tools. Requires
  `google-genai` SDK. ~50 LOC.
- **Streaming support across all providers** — current executor is non-streaming
  (single chat.completions.create call). Streaming works with openai.AsyncOpenAI
  but hasn't been wired to the A2A event queue path. ~30 LOC.
- **Per-provider model overrides in config.yaml** — Phase 1 uses the registry's
  default_model. Phase 2 adds a `hermes: { provider: qwen, model: qwen3-coder-plus }`
  block in the workspace config.
- **`.env.example` updates** — not critical since the registry itself documents
  every env var via the `env_vars` field, but nice-to-have.

## Related
- Queued memory: `project_hermes_multi_provider.md`
- CEO directive 2026-04-15: *"once current works are cleared, I want you to
  focus on supporting hermes agent, right now it doesnt take too much providers"*
- `docs/ecosystem-watch.md` → `### Hermes Agent` — Research Lead's eco-watch
  entry listed "Nous Portal, OpenRouter, GLM, Kimi, MiniMax, OpenAI, …" which
  shaped this registry's initial set

## Test plan
- [x] Unit tests: 26/26 pass locally (pytest)
- [ ] CI will run on the self-hosted macOS arm64 runner
- [ ] Smoke test in a real workspace: set QWEN_API_KEY and verify Technical
      Researcher actually hits Alibaba DashScope successfully
- [ ] Integration test per provider with real API keys (gated on env, skip
      when not set — Phase 2 CI addition)
2026-04-15 11:14:35 -07:00

164 lines
5.6 KiB
Python

"""Tests for workspace-template/adapters/hermes/providers.py.
These tests exercise resolve_provider() in isolation — they do not import
anything from adapters/__init__.py so they don't need the a2a runtime deps.
"""
from __future__ import annotations
import importlib
import os
import sys
from pathlib import Path
import pytest
# Make the hermes package importable without pulling in adapters/__init__.py
# (which imports the a2a SDK). We load providers.py directly from its file path.
_HERMES_DIR = Path(__file__).parent.parent / "adapters" / "hermes"
sys.path.insert(0, str(_HERMES_DIR))
import providers # type: ignore # noqa: E402
_ALL_PROVIDER_ENV_VARS = (
"HERMES_API_KEY",
"NOUS_API_KEY",
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"XAI_API_KEY",
"GROK_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"QWEN_API_KEY",
"DASHSCOPE_API_KEY",
"GLM_API_KEY",
"ZHIPU_API_KEY",
"KIMI_API_KEY",
"MOONSHOT_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"GROQ_API_KEY",
"TOGETHER_API_KEY",
"FIREWORKS_API_KEY",
"MISTRAL_API_KEY",
)
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
"""Clear every provider env var before each test so runs are deterministic."""
for key in _ALL_PROVIDER_ENV_VARS:
monkeypatch.delenv(key, raising=False)
yield
def test_registry_is_populated():
"""Phase 1 ships at least 15 providers and every entry is self-consistent."""
assert len(providers.PROVIDERS) >= 15
assert len(providers.RESOLUTION_ORDER) == len(providers.PROVIDERS)
for name, cfg in providers.PROVIDERS.items():
assert cfg.name == name, f"{name}: config.name should match dict key"
assert cfg.env_vars, f"{name}: must declare at least one env var"
assert cfg.base_url.startswith("http"), f"{name}: base_url must be http(s)"
assert cfg.default_model, f"{name}: must declare a default model"
assert name in providers.RESOLUTION_ORDER, f"{name}: missing from resolution order"
def test_resolution_order_has_no_duplicates():
assert len(providers.RESOLUTION_ORDER) == len(set(providers.RESOLUTION_ORDER))
def test_backcompat_hermes_api_key_first():
"""PR 2 back-compat — HERMES_API_KEY auto-detect still routes to Nous Portal."""
os.environ["HERMES_API_KEY"] = "hermes-test-key"
cfg, key = providers.resolve_provider()
assert cfg.name == "nous_portal"
assert key == "hermes-test-key"
def test_backcompat_openrouter_api_key_second():
"""PR 2 back-compat — OPENROUTER_API_KEY still routes to OpenRouter when HERMES_API_KEY is absent."""
os.environ["OPENROUTER_API_KEY"] = "or-test-key"
cfg, key = providers.resolve_provider()
assert cfg.name == "openrouter"
def test_auto_detect_openai():
os.environ["OPENAI_API_KEY"] = "sk-test"
cfg, key = providers.resolve_provider()
assert cfg.name == "openai"
assert cfg.base_url == "https://api.openai.com/v1"
def test_auto_detect_anthropic():
os.environ["ANTHROPIC_API_KEY"] = "ant-test"
cfg, key = providers.resolve_provider()
assert cfg.name == "anthropic"
@pytest.mark.parametrize(
"env_var,expected",
[
("XAI_API_KEY", "xai"),
("GROK_API_KEY", "xai"),
("QWEN_API_KEY", "qwen"),
("DASHSCOPE_API_KEY", "qwen"),
("GLM_API_KEY", "glm"),
("ZHIPU_API_KEY", "glm"),
("KIMI_API_KEY", "kimi"),
("MOONSHOT_API_KEY", "kimi"),
("GROQ_API_KEY", "groq"),
("DEEPSEEK_API_KEY", "deepseek"),
("MISTRAL_API_KEY", "mistral"),
("TOGETHER_API_KEY", "together"),
("FIREWORKS_API_KEY", "fireworks"),
("MINIMAX_API_KEY", "minimax"),
("GEMINI_API_KEY", "gemini"),
("GOOGLE_API_KEY", "gemini"),
],
)
def test_every_provider_env_var_resolves(env_var, expected):
"""Every env var listed in PROVIDERS resolves to the right provider
— this guards against typos in the registry dict."""
os.environ[env_var] = "test-key"
cfg, _ = providers.resolve_provider()
assert cfg.name == expected, (
f"{env_var} should route to {expected}, got {cfg.name}"
)
def test_explicit_provider_wins_over_auto_detect():
"""When `provider=` is given, auto-detect is bypassed."""
os.environ["HERMES_API_KEY"] = "hermes-key" # would auto-detect
os.environ["OPENAI_API_KEY"] = "openai-key"
cfg, key = providers.resolve_provider("openai")
assert cfg.name == "openai"
assert key == "openai-key"
def test_unknown_provider_raises():
with pytest.raises(ValueError, match="Unknown Hermes provider"):
providers.resolve_provider("this_provider_does_not_exist")
def test_explicit_provider_with_missing_env_raises():
"""If the operator asks for a specific provider but its env var is empty,
we raise — we do NOT fall back to auto-detect because that would be
surprising ("why is my openai config talking to anthropic?")."""
os.environ["HERMES_API_KEY"] = "some-value" # auto-detect would succeed
with pytest.raises(ValueError, match="no env var set"):
providers.resolve_provider("anthropic")
def test_auto_detect_with_no_env_lists_all_options():
"""The error message should list every env var the caller could set,
so operators don't have to read the source."""
# No env vars set (autouse fixture clears them all)
with pytest.raises(ValueError) as exc_info:
providers.resolve_provider()
msg = str(exc_info.value)
# Spot-check: the message names at least a few providers
for env_var in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "QWEN_API_KEY"):
assert env_var in msg, f"error message should mention {env_var}"