wip wip wip pluginify everything

This commit is contained in:
ethernet 2026-05-26 10:29:50 -04:00
parent 4117fc3645
commit 10421ec9ee
308 changed files with 8019 additions and 5246 deletions

2
.gitignore vendored
View file

@ -41,7 +41,7 @@ agent-browser/
privvy*
images/
__pycache__/
hermes_agent.egg-info/
*.egg-info
wandb/
testlogs

201
AGENTS.md
View file

@ -29,7 +29,9 @@ hermes-agent/
├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py # Parallel batch processing
├── _build_backend.py # Custom PEP 517 build backend — inlines plugin deps at wheel build time
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
│ └── plugin_registries.py # Typed capability registries (auth, transport, platform, tool, model_metadata)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
@ -39,16 +41,20 @@ hermes-agent/
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
├── plugins/ # Plugin system (see "Plugins" section below)
├── plugins/ # Plugin packages — uv workspace members (see "Plugins" section)
│ ├── model-providers/ # anthropic, bedrock, azure-foundry (own pyproject.toml each)
│ ├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
│ ├── tts/ # Text-to-speech plugin
│ ├── stt/ # Speech-to-text plugin
│ ├── image_gen/ # FAL image generation
│ ├── terminals/ # daytona, modal, vercel
│ ├── web/ # exa, firecrawl, parallel
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
│ └── <others>/ # dashboard, google_meet, spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@ -486,9 +492,102 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
## Plugins
Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
repo-shipped plugins can be discovered alongside user-installed ones in
`~/.hermes/plugins/` and pip-installed entry points.
Hermes uses a **plugin-first architecture**: every optional capability (model
providers, platform adapters, TTS/STT, terminal backends, image generation)
lives in its own installable Python package under `plugins/`. The core
codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) **never** imports
from a `hermes_agent_*` plugin package directly. Instead, plugins register
their capabilities into typed registries during `register()`, and the core
queries those registries at runtime.
Full architecture doc: `website/docs/developer-guide/plugin-architecture.md`
### Workspace layout
All 21 builtin plugins are uv workspace members — each has its own
`pyproject.toml` (single source of truth for deps), `plugin.yaml`
(directory-scanner manifest for dev mode), and `hermes_agent_<name>/` package
with `register(ctx)`:
```
plugins/
├── model-providers/ # anthropic, bedrock, azure-foundry
├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
├── tts/ # text-to-speech (Edge TTS + ElevenLabs)
├── stt/ # speech-to-text
├── image_gen/fal_pkg/ # FAL image generation
├── terminals/ # daytona, modal, vercel
├── web/ # exa, firecrawl, parallel
├── memory/ # honcho, hindsight
├── dashboard/ # streamlit dashboard
└── hermes-achievements/ # gamified achievement tracking
```
### The hermetic core boundary
Core code MUST NOT import from `hermes_agent_*` packages. Instead it queries
typed registries in `agent/plugin_registries.py`:
```python
# ❌ BAD — core directly imports plugin
from hermes_agent_bedrock import has_aws_credentials
# ✅ GOOD — core queries the registry
from agent.plugin_registries import registries
bedrock_auth = registries.get_auth_provider("bedrock")
```
Registry types: `auth_providers`, `transport_builders`, `platform_adapters`,
`tool_providers`, `model_metadata`, `credential_pools`.
Each plugin's `register(ctx)` populates the registries via `ctx.register_*()`:
- `ctx.register_auth_provider(name, provider, ...)`
- `ctx.register_transport(name, builder, ...)`
- `ctx.register_platform(name, label, adapter_factory, check_fn, ...)`
- `ctx.register_tool_provider(entry, ...)`
- `ctx.register_model_metadata(entry, ...)`
- `ctx.register_credential_pool(entry, ...)`
- Plus existing: `register_tool()`, `register_hook()`, `register_cli_command()`,
`register_tts_provider()`, `register_transcription_provider()`,
`register_image_gen_provider()`, `register_video_gen_provider()`,
`register_context_engine()`
### Plugin discovery
Three discovery paths (same as before, now workspace-aware):
1. **Directory scanner**`plugins/`, `~/.hermes/plugins/`, `.hermes/plugins/`
(looks for `plugin.yaml`)
2. **Entry points**`[project.entry-points."hermes_agent.plugins"]`
3. **uv workspace**`uv sync --extra <name>` installs the plugin into venv
### Dependency management
- Each plugin's `pyproject.toml` is the **only** place its deps are declared
- Root `pyproject.toml` maps extras to workspace members:
`telegram = ["hermes-agent-telegram"]`
- `uv.lock` resolves the whole workspace (236 packages)
- No `LAZY_DEPS`, no `ensure()`, no runtime `pip install`
- Custom PEP 517 build backend (`_build_backend.py`) inlines plugin deps
at wheel build time for PyPI publishing
### NixOS
`loadWorkspace` discovers all workspace members from `uv.lock` automatically.
`mkVirtualEnv { hermes-agent = ["all"] }` installs all plugins. Select specific
plugins with `extraDependencyGroups = ["telegram", "anthropic"]`.
### Tests
Plugin tests live in `plugins/<category>/<name>/tests/`. The test runner
discovers both `tests/` and `plugins/`. Running plugin tests requires the
plugin to be installed (`uv sync --extra <name>`).
### The rule
**If it can be a plugin, it must be a plugin.** Adding optional capabilities
to core files is a code review rejection. If the plugin surface doesn't
support what you need, extend the surface (new registry type, new hook, new
`ctx` method) — don't inline the capability.
### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
@ -531,9 +630,14 @@ providers don't clutter `hermes --help`.
**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
If a plugin needs a capability the framework doesn't expose, expand the
generic plugin surface (new hook, new ctx method) — never hardcode
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
honcho argparse from `main.py` for exactly this reason.
generic plugin surface (new hook, new ctx method, new registry type) — never
hardcode plugin-specific logic into core. PR #5295 removed 95 lines of
hardcoded honcho argparse from `main.py` for exactly this reason.
**Hermetic core boundary (May 2026):** core code (`agent/`, `hermes_cli/`,
`gateway/`, `tools/`) MUST NOT import from `hermes_agent_*` plugin packages.
Use the typed registries in `agent/plugin_registries.py` instead. See the
**Plugins** section below for the full list of registry types.
**No new in-tree memory providers (policy, May 2026):** the set of
built-in memory providers under `plugins/memory/` is closed. New memory
@ -1011,40 +1115,41 @@ def profile_env(tmp_path, monkeypatch):
## Testing
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
**ALWAYS use `scripts/run_tests.sh`** — do NOT call `pytest` directly on a directory.
The script enforces hermetic environment parity with CI and provides per-file
process isolation that prevents registry singleton / module-level state leakage
between test files.
```bash
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py # one file
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
### Subprocess-per-test isolation
For a **single test file or specific test**, bare `pytest` is fine:
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
ContextVars from one test cannot leak into the next — the historic
`_reset_module_state` autouse fixture is gone.
```bash
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py -q
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py::test_x --tb=short
```
Implementation notes:
Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning
from `conftest.py` telling you to use the script instead.
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
Linux, macOS, and Windows alike (POSIX `fork` is not used).
- Per-test overhead is ~0.51.0s (Python startup + pytest collection). xdist
parallelism amortizes this across cores; on a 20-core box the full suite
finishes in roughly the same wall time as before, but flake-free.
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
Hangs are killed and surfaced as a failure report.
- Pass `--no-isolate` to disable isolation — useful when debugging a single
test interactively, or when you specifically want to verify state leakage.
- The plugin disables itself in child processes (sentinel envvar
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
### Per-file process isolation
`scripts/run_tests.sh` calls `scripts/run_tests_parallel.py`, which spawns one
`python -m pytest <file>` subprocess per test **file** (not per test), giving each
a fresh Python interpreter. This means module-level dicts/sets, ContextVars, and
registry singletons from one test file cannot leak into another — no shared state
between files, no xdist required.
`HERMES_PARALLEL_RUNNER=1` is set in each subprocess so `conftest.py` knows tests
are running under the managed runner. If you need to suppress the bare-pytest
directory warning in a special case, set this variable yourself — but prefer the
script.
### Why the wrapper (and why the old "just call pytest" doesn't work)
@ -1056,31 +1161,13 @@ Five real sources of local-vs-CI drift the script closes:
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
| File isolation | Shared interpreter — state leaks between files | One subprocess per file |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
is belt-and-suspenders.
`tests/conftest.py` also enforces the credential/TZ/locale points as an autouse
fixture so ANY pytest invocation (including IDE integrations) gets hermetic
behavior — but the wrapper adds per-file process isolation on top.
### Running without the wrapper (only if you must)
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
at minimum activate the venv. The isolation plugin loads automatically from
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
either way.
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pytest tests/ -q
```
If you need to bypass isolation for fast feedback while debugging:
```bash
python -m pytest tests/agent/test_foo.py -q --no-isolate
```
Always run the full suite before pushing changes.
Always run the full suite via `scripts/run_tests.sh` before pushing changes.
### Don't write change-detector tests

View file

@ -121,12 +121,11 @@ hermes chat -q "Hello"
### Run tests
```bash
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
# for parity with GitHub Actions before you open a PR:
pytest tests/ -v
# For a single file or specific test, bare pytest is also fine:
# python -m pytest tests/agent/test_foo.py -q
```
---
@ -857,7 +856,7 @@ refactor/description # Code restructuring
### Before submitting
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI — hermetic env + per-file process isolation)
2. **Test manually**: Run `hermes` and exercise the code path you changed
3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2
4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature.

View file

@ -179,7 +179,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv venv --python 3.11
source venv/bin/activate
uv pip install -e ".[all,dev]"
python -m pytest tests/ -q
scripts/run_tests.sh
```
---

183
_build_backend.py Normal file
View file

@ -0,0 +1,183 @@
"""Custom PEP 517 build backend for hermes-agent.
At wheel build time, rewrites [project.optional-dependencies] so that
plugin extras (e.g. ``anthropic = ["hermes-agent-anthropic"]``) are
inlined with the actual deps from each plugin's pyproject.toml.
In the source repo (and on Nix), uv resolves workspace members natively
so this backend is NOT used it's only invoked when building a wheel
for PyPI publication.
Usage in pyproject.toml::
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "_build_backend"
backend-path = ["."]
How it works:
1. ``build_wheel`` intercepts the call before setuptools sees pyproject.toml.
2. It reads the workspace member dirs from [tool.uv.workspace].members.
3. For each member, it reads the member's pyproject.toml and extracts
``project.dependencies`` (excluding the ``hermes-agent`` base dep).
4. It rewrites the main pyproject.toml's optional-dependencies to inline
those deps instead of the workspace member references.
5. It writes a temporary pyproject.toml, delegates to
``setuptools.build_meta.build_wheel``, then restores the original.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
import tomllib
# The original setuptools backend we delegate to.
_BACKEND = "setuptools.build_meta"
def _load_pyproject(path: Path) -> dict:
with path.open("rb") as f:
return tomllib.load(f)
def _save_pyproject(path: Path, data: dict) -> None:
"""Write a pyproject.toml. Uses a simple serializer since we only
need to preserve the structure enough for setuptools to parse."""
import tomli_w
with path.open("wb") as f:
tomli_w.dump(data, f)
def _inline_plugin_deps(root: Path, data: dict) -> dict:
"""Rewrite optional-dependencies to inline plugin deps.
Maps each plugin extra (e.g. ``anthropic = ["hermes-agent-anthropic"]``)
to the actual deps from that plugin's pyproject.toml, minus the
``hermes-agent`` base dependency.
"""
opt_deps = data.get("project", {}).get("optional-dependencies", {})
workspace = data.get("tool", {}).get("uv", {}).get("workspace", {})
members = workspace.get("members", [])
# Build a map: package name → (member_dir, pyproject_data)
pkg_to_deps: dict[str, list[str]] = {}
for member_glob in members:
for member_dir in sorted(root.glob(member_glob)):
pptoml = member_dir / "pyproject.toml"
if not pptoml.exists():
continue
member_data = _load_pyproject(pptoml)
pkg_name = member_data.get("project", {}).get("name", "")
if not pkg_name:
continue
# Extract deps, excluding the base hermes-agent dependency
raw_deps = member_data.get("project", {}).get("dependencies", [])
filtered = [
d for d in raw_deps
if not d.replace(" ", "").startswith("hermes-agent")
]
pkg_to_deps[pkg_name] = filtered
# Rewrite optional-dependencies
new_opt_deps = {}
for extra_name, specs in opt_deps.items():
new_specs = []
for spec in specs:
# Check if this spec references a workspace member package
if spec in pkg_to_deps:
# Inline the plugin's deps
new_specs.extend(pkg_to_deps[spec])
else:
new_specs.append(spec)
new_opt_deps[extra_name] = new_specs
data["project"]["optional-dependencies"] = new_opt_deps
# Remove [tool.uv] section — it's not valid in a published wheel
if "uv" in data.get("tool", {}):
del data["tool"]["uv"]
return data
# ---------------------------------------------------------------------------
# PEP 517 hooks
# ---------------------------------------------------------------------------
def build_wheel(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build a wheel with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
# Read and rewrite
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
# Write a temporary pyproject.toml, build, then restore
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
# Delegate to setuptools
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_wheel(wheel_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Build an sdist — no rewriting needed."""
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_sdist(sdist_directory, config_settings)
def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0", "tomli_w"]
def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]
def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Prepare metadata with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
import importlib
backend = importlib.import_module(_BACKEND)
return backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_editable(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build an editable install — no rewriting needed (dev mode)."""
import importlib
backend = importlib.import_module(_BACKEND)
kwargs: dict[str, Any] = {"config_settings": config_settings}
if metadata_directory is not None:
kwargs["metadata_directory"] = metadata_directory
return backend.build_editable(wheel_directory, **kwargs)
def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]

View file

@ -6,7 +6,9 @@ from typing import Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
@ -176,7 +178,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:
return None
if not _is_oauth_token(token):
if _is_oauth_token is not None and not _is_oauth_token(token):
return AccountUsageSnapshot(
provider="anthropic",
source="oauth_usage_api",

View file

@ -583,12 +583,14 @@ def init_agent(
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
# (prompt caching, thinking budgets, adaptive thinking).
_is_bedrock_anthropic = agent.provider == "bedrock"
if _is_bedrock_anthropic:
from agent.anthropic_adapter import build_anthropic_bedrock_client
build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client")
_region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
_br_region = _region_match.group(1) if _region_match else "us-east-1"
agent._bedrock_region = _br_region
@ -642,8 +644,8 @@ def init_agent(
# so injects Claude-Code identity headers and system prompts
# that cause 401/403 on their endpoints. Guards #1739 and
# the third-party identity-injection bug.
from agent.anthropic_adapter import _is_oauth_token as _is_oat
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
# No OpenAI client needed for Anthropic mode
agent.client = None
@ -655,9 +657,10 @@ def init_agent(
# The Anthropic adapter installs an httpx event hook
# that mints a fresh JWT per request — we never
# invoke or inspect the callable in the banner.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
if is_token_provider(effective_key):
if is_token_provider and is_token_provider(effective_key):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
@ -867,10 +870,11 @@ def init_agent(
# provider (Azure Foundry). The OpenAI SDK mints a
# fresh JWT per request internally — the banner
# never invokes or inspects the callable.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
key_used = client_kwargs.get("api_key", "none")
if is_token_provider(key_used):
if is_token_provider and is_token_provider(key_used):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12:
print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}")

View file

@ -748,7 +748,8 @@ def try_recover_primary_transport(
agent.api_key = rt["api_key"]
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@ -912,7 +913,8 @@ def restore_primary_runtime(agent) -> bool:
# ── Rebuild client for the primary provider ──
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@ -1385,11 +1387,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Build new client ──
if api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
resolve_anthropic_token,
_is_oauth_token,
)
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
# API key — falling back would send Anthropic credentials to third-party endpoints.
@ -1418,7 +1419,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
effective_key, agent._anthropic_base_url,
timeout=get_provider_request_timeout(agent.provider, agent.model),
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False
agent.client = None
agent._client_kwargs = {}
else:

View file

@ -971,7 +971,8 @@ class _AnthropicCompletionsAdapter:
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.plugin_registries import registries
build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs")
from agent.transports import get_transport
messages = kwargs.get("messages", [])
@ -1011,8 +1012,8 @@ class _AnthropicCompletionsAdapter:
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if not _forbids_sampling_params(model):
_forbids_sampling_params = registries.get_provider_service("anthropic", "_forbids_sampling_params")
if _forbids_sampling_params is None or not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
@ -1182,7 +1183,8 @@ def _maybe_wrap_anthropic(
return client_obj
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
except ImportError:
logger.warning(
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
@ -1824,7 +1826,11 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
# Anthropic OAuth claims only apply to api.anthropic.com.
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@ -2027,10 +2033,28 @@ def _try_azure_foundry(
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
try:
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
except ImportError:
return None, None
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if build_anthropic_client is None or resolve_anthropic_token is None:
# Registry empty (e.g. unit tests without plugin loading) — use module directly
# so that mock.patch("hermes_agent_anthropic.adapter.X") still intercepts calls.
try:
import hermes_agent_anthropic.adapter as _ant_mod # type: ignore[import]
class _ModuleNamespace:
"""Proxy that delegates .get() to module attribute lookup (live, patchable)."""
def get(self, name: str, default=None):
return getattr(_ant_mod, name, default)
_anthropic = _ModuleNamespace()
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if build_anthropic_client is None or resolve_anthropic_token is None:
return None, None
except ImportError:
return None, None
pool_present, entry = _select_pool_entry("anthropic")
if pool_present:
@ -2060,8 +2084,8 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
except Exception:
pass
from agent.anthropic_adapter import _is_oauth_token
is_oauth = _is_oauth_token(token)
_is_oauth_token = _anthropic.get("_is_oauth_token")
is_oauth = _is_oauth_token(token) if _is_oauth_token else False
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
@ -2715,12 +2739,19 @@ def _refresh_provider_credentials(provider: str) -> bool:
_evict_cached_clients(normalized)
return True
if normalized == "anthropic":
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_refresh_oauth_token = _anthropic.get("_refresh_oauth_token")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if read_claude_code_credentials is None:
return False
creds = read_claude_code_credentials()
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None
if not str(token or "").strip():
token = resolve_anthropic_token()
if resolve_anthropic_token is not None:
token = resolve_anthropic_token()
if not str(token or "").strip():
return False
_evict_cached_clients(normalized)
@ -3459,7 +3490,11 @@ def resolve_provider_client(
# branch in _try_custom_endpoint(). See #15033.
if entry_api_mode == "anthropic_messages":
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@ -3697,8 +3732,14 @@ def resolve_provider_client(
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.anthropic_adapter import build_anthropic_bedrock_client
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_anthropic = registries.get_provider_namespace("anthropic")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
build_anthropic_bedrock_client = _anthropic.get("build_anthropic_bedrock_client")
if has_aws_credentials is None or resolve_bedrock_region is None or build_anthropic_bedrock_client is None:
raise ImportError("bedrock or anthropic provider not registered")
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
"boto3 or anthropic SDK not installed")
@ -4670,8 +4711,10 @@ def _build_call_kwargs(
# structured-JSON extraction) don't 400 the moment
# the aux model is flipped to 4.7.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if _forbids_sampling_params(model):
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_forbids_sampling_params = _anthropic.get("_forbids_sampling_params")
if _forbids_sampling_params is not None and _forbids_sampling_params(model):
temperature = None
if temperature is not None:

View file

@ -217,12 +217,14 @@ def interruptible_api_call(agent, api_kwargs: dict):
# normalize_converse_response produces an OpenAI-compatible
# SimpleNamespace so the rest of the agent loop can treat
# bedrock responses like chat_completions responses.
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
normalize_converse_response,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
normalize_converse_response = _bedrock.get("normalize_converse_response")
if _get_bedrock_runtime_client is None or normalize_converse_response is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
@ -559,8 +561,11 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_ant_max = None
if (_is_or or _is_nous) and "claude" in (agent.model or "").lower():
try:
from agent.anthropic_adapter import _get_anthropic_max_output
_ant_max = _get_anthropic_max_output(agent.model)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_get_anthropic_max_output = _anthropic.get("_get_anthropic_max_output")
if _get_anthropic_max_output is not None:
_ant_max = _get_anthropic_max_output(agent.model)
except Exception:
pass
@ -1026,15 +1031,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if fb_api_mode == "anthropic_messages":
# Build native Anthropic client instead of using OpenAI client
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
_is_oauth_token = _anthropic.get("_is_oauth_token")
effective_key = (fb_client.api_key or (resolve_anthropic_token() if resolve_anthropic_token else "") or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = fb_base_url
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False
if build_anthropic_client is not None:
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" and _is_oauth_token else False
agent.client = None
agent._client_kwargs = {}
else:
@ -1418,12 +1428,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
def _bedrock_call():
try:
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
stream_converse_with_callbacks,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
stream_converse_with_callbacks = _bedrock.get("stream_converse_with_callbacks")
if _get_bedrock_runtime_client is None or stream_converse_with_callbacks is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)

View file

@ -27,7 +27,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from agent.anthropic_adapter import _is_oauth_token
from agent.plugin_registries import registries as _registries
from agent.auxiliary_client import set_runtime_main
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.display import KawaiiSpinner
@ -2238,8 +2238,8 @@ def run_conversation(
and not anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
from agent.azure_identity_adapter import is_token_provider
_is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token")
is_token_provider = _registries.get_provider_service("azure", "is_token_provider")
if agent._try_refresh_anthropic_client_credentials():
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
continue
@ -2256,7 +2256,7 @@ def run_conversation(
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
print(f"{agent.log_prefix} `az login` if your developer session expired.")
else:
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 is not None and _is_oauth_token(key)) else "x-api-key (API key)"
print(f"{agent.log_prefix} Auth method: {auth_method}")
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
print(f"{agent.log_prefix} Troubleshooting:")

View file

@ -469,7 +469,8 @@ class CredentialPool:
if self.provider != "anthropic" or entry.source != "claude_code":
return entry
try:
from agent.anthropic_adapter import read_claude_code_credentials
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
creds = read_claude_code_credentials()
if not creds:
return entry
@ -785,7 +786,8 @@ class CredentialPool:
try:
if self.provider == "anthropic":
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
refreshed = refresh_anthropic_oauth_pure(
entry.refresh_token,
@ -802,7 +804,7 @@ class CredentialPool:
# see the latest tokens.
if entry.source == "claude_code":
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
@ -872,7 +874,8 @@ class CredentialPool:
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
@ -889,7 +892,7 @@ class CredentialPool:
self._replace_entry(synced, updated)
self._persist()
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
@ -1527,7 +1530,9 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
except ImportError:
pass
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
read_hermes_oauth_credentials = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials")
for source_name, creds in (
("hermes_pkce", read_hermes_oauth_credentials()),

View file

@ -1545,8 +1545,11 @@ def get_model_context_length(
and base_url_host_matches(base_url, "amazonaws.com")
):
try:
from agent.bedrock_adapter import get_bedrock_context_length
return get_bedrock_context_length(model)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
get_bedrock_context_length = _bedrock.get("get_bedrock_context_length")
if get_bedrock_context_length is not None:
return get_bedrock_context_length(model)
except ImportError:
pass # boto3 not installed — fall through to generic resolution

384
agent/plugin_registries.py Normal file
View file

@ -0,0 +1,384 @@
"""Plugin capability registries.
Each plugin's ``register(ctx)`` function populates these registries via
``ctx.register_<capability>()``. The core codebase then queries the
registries instead of importing from plugin packages directly.
This is the **only** coupling point between the core and plugins: the core
imports from ``agent.plugin_registries``, never from ``hermes_agent_*``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Sequence,
Type,
runtime_checkable,
)
# ---------------------------------------------------------------------------
# Auth providers
# ---------------------------------------------------------------------------
@runtime_checkable
class AuthProvider(Protocol):
"""A plugin that can provide or check authentication credentials.
Registered via ``ctx.register_auth_provider(name, provider)``.
Queried by ``hermes_cli/auth_commands.py``, ``doctor.py``, etc.
"""
@property
def name(self) -> str: ...
def has_credentials(self) -> bool:
"""Return True if the required credentials are present in env/config."""
...
def check_env_vars(self) -> Dict[str, str | None]:
"""Return a dict of env-var-name → current-value (or None if unset).
Used by ``hermes doctor`` to display credential status.
"""
...
def resolve_token(self, **kwargs: Any) -> Any:
"""Resolve and return an auth token/credential for the provider.
The return type is provider-specific (string, tuple, object, etc.).
"""
...
def refresh_token(self, **kwargs: Any) -> Any:
"""Refresh an existing token. Raises if refresh is not supported."""
...
@dataclass
class AuthProviderEntry:
provider: AuthProvider
"""The auth provider instance."""
cli_group: str = ""
"""CLI argument group name (e.g. 'Anthropic', 'AWS / Bedrock')."""
setup_subcommands: bool = False
"""Whether this provider adds CLI auth subcommands (login, logout, etc.)."""
# ---------------------------------------------------------------------------
# Transport builders
# ---------------------------------------------------------------------------
@runtime_checkable
class TransportBuilder(Protocol):
"""A plugin that builds clients and converts messages for a model transport.
Registered via ``ctx.register_transport(name, builder)``.
Queried by ``agent/transports/`` and ``agent/auxiliary_client.py``.
"""
def build_client(self, **kwargs: Any) -> Any:
"""Build and return a provider-specific API client."""
...
def build_kwargs(self, **kwargs: Any) -> Dict[str, Any]:
"""Build the kwargs dict for a provider-specific API call."""
...
def convert_messages(self, messages: Sequence[Any], **kwargs: Any) -> Any:
"""Convert internal message format to provider-specific format."""
...
def convert_tools(self, tools: Sequence[Any], **kwargs: Any) -> Any:
"""Convert internal tool format to provider-specific format."""
...
def normalize_response(self, response: Any, **kwargs: Any) -> Any:
"""Normalize a provider-specific response into the internal format."""
...
# ---------------------------------------------------------------------------
# Platform adapters
# ---------------------------------------------------------------------------
@dataclass
class PlatformAdapterEntry:
"""A registered platform adapter.
Registered via ``ctx.register_platform(name, entry)``.
Queried by ``gateway/run.py`` and ``tools/send_message_tool.py``.
"""
name: str
"""Platform identifier (e.g. 'telegram', 'slack')."""
adapter_class: Type
"""The adapter class (e.g. TelegramAdapter)."""
check_requirements: Callable[[], bool]
"""Check if the platform's dependencies are installed and configured."""
available_flag: str = ""
"""Name of the module-level AVAILABLE boolean, if any."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Platform-specific constants (e.g. FEISHU_DOMAIN, LARK_DOMAIN)."""
helper_functions: Dict[str, Callable] = field(default_factory=dict)
"""Platform-specific helper functions (e.g. probe_bot, qr_register)."""
# ---------------------------------------------------------------------------
# Tool providers
# ---------------------------------------------------------------------------
@dataclass
class ToolProviderEntry:
"""A registered tool provider.
Registered via ``ctx.register_tool_provider(name, entry)``.
Queried by ``tools/`` modules.
"""
name: str
"""Tool identifier (e.g. 'tts', 'stt', 'fal', 'daytona')."""
tool_functions: Dict[str, Callable] = field(default_factory=dict)
"""Tool functions keyed by name (e.g. 'text_to_speech_tool', 'transcribe_audio')."""
check_fn: Optional[Callable] = None
"""Check if the tool's dependencies are available."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Tool-specific constants (e.g. MAX_FILE_SIZE)."""
config_functions: Dict[str, Callable] = field(default_factory=dict)
"""Config/utility functions (e.g. _get_provider, _load_stt_config)."""
environment_classes: Dict[str, Type] = field(default_factory=dict)
"""Environment classes for terminal backends (e.g. DaytonaEnvironment)."""
# ---------------------------------------------------------------------------
# Model metadata providers
# ---------------------------------------------------------------------------
@dataclass
class ModelMetadataEntry:
"""A registered model metadata provider.
Registered via ``ctx.register_model_metadata(name, entry)``.
Queried by ``agent/model_metadata.py`` and CLI model commands.
"""
name: str
"""Provider identifier (e.g. 'anthropic', 'bedrock')."""
get_context_length: Optional[Callable[[str], int | None]] = None
"""Return the context length for a model name, or None if unknown."""
list_models: Optional[Callable[[], List[str]]] = None
"""Return a list of known model IDs for this provider."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Provider-specific constants (e.g. _COMMON_BETAS, betas lists)."""
# ---------------------------------------------------------------------------
# Credential pool entries
# ---------------------------------------------------------------------------
@dataclass
class CredentialPoolEntry:
"""A registered credential pool provider.
Registered via ``ctx.register_credential_pool(name, entry)``.
Queried by ``agent/credential_pool.py``.
"""
name: str
"""Provider identifier (e.g. 'anthropic')."""
read_credentials: Optional[Callable] = None
"""Read stored credentials."""
write_credentials: Optional[Callable] = None
"""Write/store credentials."""
refresh_credentials: Optional[Callable] = None
"""Refresh stored credentials."""
read_oauth: Optional[Callable] = None
"""Read OAuth credentials."""
# ---------------------------------------------------------------------------
# The global registries (singleton)
# ---------------------------------------------------------------------------
class PluginRegistries:
"""Central store for all plugin-registered capabilities.
A single instance is created at import time and shared across the
process. Plugins populate it during ``register()``; the core
queries it at runtime.
"""
def __init__(self) -> None:
self.auth_providers: Dict[str, AuthProviderEntry] = {}
self.transport_builders: Dict[str, TransportBuilder] = {}
self.platform_adapters: Dict[str, PlatformAdapterEntry] = {}
self.tool_providers: Dict[str, ToolProviderEntry] = {}
self.model_metadata: Dict[str, ModelMetadataEntry] = {}
self.credential_pools: Dict[str, CredentialPoolEntry] = {}
self._provider_services: Dict[str, Dict[str, Any]] = {}
# -- registration methods (called from PluginContext) --------------------
def register_auth_provider(
self,
name: str,
provider: AuthProvider,
*,
cli_group: str = "",
setup_subcommands: bool = False,
) -> None:
self.auth_providers[name] = AuthProviderEntry(
provider=provider,
cli_group=cli_group,
setup_subcommands=setup_subcommands,
)
def register_transport(self, name: str, builder: TransportBuilder) -> None:
self.transport_builders[name] = builder
def register_platform(self, entry: PlatformAdapterEntry) -> None:
self.platform_adapters[entry.name] = entry
def register_tool_provider(self, entry: ToolProviderEntry) -> None:
self.tool_providers[entry.name] = entry
def register_model_metadata(self, entry: ModelMetadataEntry) -> None:
self.model_metadata[entry.name] = entry
def register_credential_pool(self, entry: CredentialPoolEntry) -> None:
self.credential_pools[entry.name] = entry
# -- query helpers -------------------------------------------------------
def get_auth_provider(self, name: str) -> AuthProviderEntry | None:
return self.auth_providers.get(name)
def get_transport(self, name: str) -> TransportBuilder | None:
return self.transport_builders.get(name)
def get_platform(self, name: str) -> PlatformAdapterEntry | None:
return self.platform_adapters.get(name)
def get_tool_provider(self, name: str) -> ToolProviderEntry | None:
return self.tool_providers.get(name)
def get_model_metadata(self, name: str) -> ModelMetadataEntry | None:
return self.model_metadata.get(name)
def get_credential_pool(self, name: str) -> CredentialPoolEntry | None:
return self.credential_pools.get(name)
def all_auth_providers(self) -> List[AuthProviderEntry]:
return list(self.auth_providers.values())
def all_platforms(self) -> List[PlatformAdapterEntry]:
return list(self.platform_adapters.values())
def all_tool_providers(self) -> List[ToolProviderEntry]:
return list(self.tool_providers.values())
# -- provider services (model-provider namespace) -----------------------
def register_provider_services(self, name: str, services: Dict[str, Any]) -> None:
"""Register a namespace dict of provider-specific services.
This is the escape hatch for model-provider plugins that expose many
symbols (anthropic has 50+). Each plugin registers its public surface
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
looks up specific symbols instead of importing from the plugin
package directly.
Each callable value is stored as a *lazy module-attribute reference*
so that ``unittest.mock.patch("pkg.mod.fn")`` works correctly in
tests the registry re-reads ``mod.fn`` on every lookup instead of
capturing the function object at register time.
Example::
registries.register_provider_services("anthropic", {
"build_anthropic_client": build_anthropic_client,
"resolve_anthropic_token": resolve_anthropic_token,
"_is_oauth_token": _is_oauth_token,
...
})
"""
import sys
def _make_lazy(fn: Any) -> Any:
"""Return a lazy wrapper that re-reads fn from its module each call.
This makes mock.patch() on the module attribute work transparently
the registry never caches the function object, just the reference path.
"""
if not callable(fn):
return fn
module = getattr(fn, "__module__", None)
qualname = getattr(fn, "__qualname__", None)
if not module or not qualname or "." in qualname:
# non-simple attribute (lambda, nested fn, class method) — store directly
return fn
class _LazyRef:
__slots__ = ("_mod", "_attr", "_fallback")
def __init__(self, mod: str, attr: str, fallback: Any) -> None:
self._mod = mod
self._attr = attr
self._fallback = fallback
def __call__(self, *args: Any, **kwargs: Any) -> Any:
mod = sys.modules.get(self._mod)
live = getattr(mod, self._attr, self._fallback) if mod else self._fallback
return live(*args, **kwargs)
def __repr__(self) -> str: # pragma: no cover
return f"<LazyRef {self._mod}.{self._attr}>"
# Allow isinstance checks and hasattr to pass through
def __bool__(self) -> bool:
return True
return _LazyRef(module, qualname, fn)
self._provider_services[name] = {k: _make_lazy(v) for k, v in services.items()}
def get_provider_service(self, provider: str, name: str) -> Any:
"""Look up a single symbol from a provider's service namespace.
Returns ``None`` if the provider is not registered or the symbol
doesn't exist.
"""
ns = self._provider_services.get(provider)
if ns is None:
return None
return ns.get(name)
def get_provider_namespace(self, provider: str) -> Dict[str, Any]:
"""Return the full service namespace dict for a provider (empty dict if unregistered)."""
return self._provider_services.get(provider, {})
# Module-level singleton — the one and only instance.
registries = PluginRegistries()

View file

@ -1,6 +1,6 @@
"""Anthropic Messages API transport.
Delegates to the existing adapter functions in agent/anthropic_adapter.py.
Delegates to the existing adapter functions in hermes_agent_anthropic.
This transport owns format conversion and normalization NOT client lifecycle.
"""
@ -13,7 +13,7 @@ from agent.transports.types import NormalizedResponse
class AnthropicTransport(ProviderTransport):
"""Transport for api_mode='anthropic_messages'.
Wraps the existing functions in anthropic_adapter.py behind the
Wraps the existing functions in hermes_agent_anthropic behind the
ProviderTransport ABC. Each method delegates no logic is duplicated.
"""
@ -27,14 +27,16 @@ class AnthropicTransport(ProviderTransport):
kwargs:
base_url: Optional[str] affects thinking signature handling.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.plugin_registries import registries
convert_messages_to_anthropic = registries.get_provider_service("anthropic", "convert_messages_to_anthropic")
base_url = kwargs.get("base_url")
return convert_messages_to_anthropic(messages, base_url=base_url)
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
from agent.anthropic_adapter import convert_tools_to_anthropic
from agent.plugin_registries import registries
convert_tools_to_anthropic = registries.get_provider_service("anthropic", "convert_tools_to_anthropic")
return convert_tools_to_anthropic(tools)
@ -60,7 +62,8 @@ class AnthropicTransport(ProviderTransport):
fast_mode: bool
drop_context_1m_beta: bool
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.plugin_registries import registries
build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs")
return build_anthropic_kwargs(
model=model,
@ -84,7 +87,8 @@ class AnthropicTransport(ProviderTransport):
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data
from agent.plugin_registries import registries
_to_plain_data = registries.get_provider_service("anthropic", "_to_plain_data")
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
@ -100,9 +104,10 @@ class AnthropicTransport(ProviderTransport):
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
if _to_plain_data is not None:
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):

View file

@ -1,6 +1,6 @@
"""AWS Bedrock Converse API transport.
Delegates to the existing adapter functions in agent/bedrock_adapter.py.
Delegates to the existing adapter functions in hermes_agent_bedrock.
Bedrock uses its own boto3 client (not the OpenAI SDK), so the transport
owns format conversion and normalization, while client construction and
boto3 calls stay on AIAgent.
@ -21,13 +21,19 @@ class BedrockTransport(ProviderTransport):
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
"""Convert OpenAI messages to Bedrock Converse format."""
from agent.bedrock_adapter import convert_messages_to_converse
return convert_messages_to_converse(messages)
from agent.plugin_registries import registries
_fn = registries.get_provider_service("bedrock", "convert_messages_to_converse")
if _fn is None:
raise ImportError("bedrock plugin not registered")
return _fn(messages)
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Bedrock Converse toolConfig."""
from agent.bedrock_adapter import convert_tools_to_converse
return convert_tools_to_converse(tools)
from agent.plugin_registries import registries
_fn = registries.get_provider_service("bedrock", "convert_tools_to_converse")
if _fn is None:
raise ImportError("bedrock plugin not registered")
return _fn(tools)
def build_kwargs(
self,
@ -46,12 +52,15 @@ class BedrockTransport(ProviderTransport):
guardrail_config: dict | None Bedrock guardrails
region: str AWS region (default 'us-east-1')
"""
from agent.bedrock_adapter import build_converse_kwargs
from agent.plugin_registries import registries
_fn = registries.get_provider_service("bedrock", "build_converse_kwargs")
if _fn is None:
raise ImportError("bedrock plugin not registered")
region = params.get("region", "us-east-1")
guardrail = params.get("guardrail_config")
kwargs = build_converse_kwargs(
kwargs = _fn(
model=model,
messages=messages,
tools=tools,
@ -71,7 +80,10 @@ class BedrockTransport(ProviderTransport):
1. Raw boto3 dict (from direct converse() calls)
2. Already-normalized SimpleNamespace with .choices (from dispatch site)
"""
from agent.bedrock_adapter import normalize_converse_response
from agent.plugin_registries import registries
normalize_converse_response = registries.get_provider_service("bedrock", "normalize_converse_response")
if normalize_converse_response is None:
raise ImportError("bedrock plugin not registered")
# Normalize to OpenAI-compatible SimpleNamespace
if hasattr(response, "choices") and response.choices:

39
cli.py
View file

@ -6235,8 +6235,10 @@ class HermesCLI:
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
# provider). Never invoke it; just identify the auth surface.
from agent.azure_identity_adapter import is_token_provider
if is_token_provider(self.api_key):
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
is_token_provider = _azure_ns.get("is_token_provider")
if is_token_provider and is_token_provider(self.api_key):
api_key_display = "Microsoft Entra ID"
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
@ -10853,7 +10855,14 @@ class HermesCLI:
return
self._voice_tts_done.clear()
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
text_to_speech_tool = _tts_provider.tool_functions.get("text_to_speech_tool")
check_tts_requirements = _tts_provider.check_fn
if text_to_speech_tool is None:
raise ImportError("text_to_speech_tool not found in tts provider")
from tools.voice_mode import play_audio_file
# Strip markdown and non-speech content for cleaner TTS
@ -11036,8 +11045,10 @@ class HermesCLI:
status = "enabled" if self._voice_tts else "disabled"
if self._voice_tts:
from tools.tts_tool import check_tts_requirements
if not check_tts_requirements():
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
check_tts_requirements = _tts_provider.check_fn if _tts_provider else None
if check_tts_requirements and not check_tts_requirements():
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
@ -11659,13 +11670,17 @@ class HermesCLI:
if self._voice_tts:
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
stream_tts_to_speaker,
)
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
_load_tts_cfg = _tts_provider.config_functions.get("_load_tts_config")
_get_prov = _tts_provider.config_functions.get("_get_provider")
_import_elevenlabs = _tts_provider.config_functions.get("_import_elevenlabs")
_import_sounddevice = _tts_provider.config_functions.get("_import_sounddevice")
stream_tts_to_speaker = _tts_provider.tool_functions.get("stream_tts_to_speaker")
if not all([_load_tts_cfg, _get_prov, stream_tts_to_speaker]):
raise ImportError("streaming TTS functions not found in tts provider")
_tts_cfg = _load_tts_cfg()
if _get_prov(_tts_cfg) == "elevenlabs":
# Verify both ElevenLabs SDK and audio output are available

View file

@ -519,6 +519,43 @@ def pytest_configure(config): # noqa: D401 — pytest hook
)
def pytest_collection_finish(session) -> None: # noqa: D401 — pytest hook
"""Warn when pytest is invoked directly instead of via the parallel runner.
The canonical runner (scripts/run_tests_parallel.py) spawns one
pytest subprocess per file, giving each a fresh Python interpreter.
This prevents cross-file module-level state leakage (especially the
plugin-registry singleton) from causing ordering-dependent failures.
Running ``pytest tests/`` directly collapses all files into one
process and WILL see spurious failures that don't exist in CI.
The runner sets HERMES_PARALLEL_RUNNER=1 in each subprocess so we
can detect the difference here.
"""
if os.environ.get("HERMES_PARALLEL_RUNNER"):
return # launched by the runner — all good
n = len(session.items)
if n < 50:
return # single-file or tiny run — fine to use bare pytest
_YELLOW = "\033[33m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
print(
f"\n{_BOLD}{_YELLOW}⚠ hermes-agent test suite warning{_RESET}\n"
f" You're running {n} tests directly under pytest.\n"
f" Cross-file module-state leakage (esp. the plugin registry\n"
f" singleton) can cause ordering-dependent failures that don't\n"
f" exist in CI.\n\n"
f" Use the canonical runner instead:\n"
f" {_BOLD}python scripts/run_tests_parallel.py{_RESET}\n\n"
f" To suppress this warning (e.g. for a focused multi-file run):\n"
f" {_BOLD}HERMES_PARALLEL_RUNNER=1 pytest ...{_RESET}\n",
file=sys.stderr,
)
@pytest.fixture(autouse=True)
def _live_system_guard(request, monkeypatch):
"""Block real os.kill / systemctl / gateway-pid scans during tests.

View file

@ -3609,8 +3609,11 @@ class BasePlatformAdapter(ABC):
and text_content
and not media_files):
try:
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
check_tts_requirements = _tts.check_fn if _tts else None
if check_tts_requirements and text_to_speech_tool and check_tts_requirements():
import json as _json
speech_text = self.prepare_tts_text(text_content)
if not speech_text:

View file

@ -6125,6 +6125,29 @@ class GatewayRunner:
# plugin adapters don't need a custom factory signature.
if hasattr(adapter, "gateway_runner"):
adapter.gateway_runner = self
# ── Telegram: notification mode from config ──
# Applied here (not in the adapter factory) because it
# reads gateway-local config that only the gateway runner
# has access to.
if platform.value == "telegram":
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
# Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms).
@ -6138,49 +6161,13 @@ class GatewayRunner:
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
# Fall through to built-in adapters below
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
logger.warning("Telegram: python-telegram-bot not installed")
return None
adapter = TelegramAdapter(config)
# Apply Telegram notification mode from config. Controls whether
# intermediate messages (tool progress, streaming, status) trigger
# push notifications. Supports ENV override for quick testing.
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
elif platform == Platform.WHATSAPP:
if platform == Platform.WHATSAPP:
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
if not check_whatsapp_requirements():
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
return None
return WhatsAppAdapter(config)
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
return None
return SlackAdapter(config)
elif platform == Platform.SIGNAL:
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
if not check_signal_requirements():
@ -6209,20 +6196,6 @@ class GatewayRunner:
return None
return SmsAdapter(config)
elif platform == Platform.DINGTALK:
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
if not check_dingtalk_requirements():
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
return None
return DingTalkAdapter(config)
elif platform == Platform.FEISHU:
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
if not check_feishu_requirements():
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM_CALLBACK:
from gateway.platforms.wecom_callback import (
WecomCallbackAdapter,
@ -6247,13 +6220,6 @@ class GatewayRunner:
return None
return WeixinAdapter(config)
elif platform == Platform.MATRIX:
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
if not check_matrix_requirements():
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
return None
return MatrixAdapter(config)
elif platform == Platform.API_SERVER:
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
if not check_api_server_requirements():
@ -11274,7 +11240,12 @@ class GatewayRunner:
audio_path = None
actual_path = None
try:
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
from agent.plugin_registries import registries
_tts_entry = registries.get_tool_provider("tts")
if _tts_entry is None:
return
text_to_speech_tool = _tts_entry.tool_functions["text_to_speech_tool"]
_strip_markdown_for_tts = _tts_entry.tool_functions["_strip_markdown_for_tts"]
tts_text = _strip_markdown_for_tts(text[:4000])
if not tts_text:
@ -14527,9 +14498,32 @@ class GatewayRunner:
return f"{prefix}\n\n{user_text}"
return prefix
from tools.transcription_tools import transcribe_audio
from agent.plugin_registries import registries
_stt_entry = registries.get_tool_provider("stt")
enriched_parts = []
if _stt_entry is None or "transcribe_audio" not in _stt_entry.tool_functions:
# No STT plugin registered — treat each audio path the same way
# as a "No STT provider" transcription failure.
for path in audio_paths:
abs_path = os.path.abspath(path)
duration_str = await _probe_audio_duration(abs_path)
if duration_str:
enriched_parts.append(
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
)
else:
enriched_parts.append(f"[The user sent a voice message: {abs_path}]")
if not enriched_parts:
return user_text
prefix = "\n\n".join(enriched_parts)
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
if user_text:
return f"{prefix}\n\n{user_text}"
return prefix
transcribe_audio = _stt_entry.tool_functions["transcribe_audio"]
for path in audio_paths:
try:
logger.debug("Transcribing user voice: %s", path)

View file

@ -1521,8 +1521,10 @@ def resolve_provider(
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
# This runs after API-key providers so explicit keys always win.
try:
from agent.bedrock_adapter import has_aws_credentials
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials and has_aws_credentials():
return "bedrock"
except ImportError:
pass # boto3 not installed — skip Bedrock auto-detection
@ -5781,8 +5783,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
# AWS SDK providers (Bedrock) — check via boto3 credential chain
if pconfig and pconfig.auth_type == "aws_sdk":
try:
from agent.bedrock_adapter import has_aws_credentials
return {"logged_in": has_aws_credentials(), "provider": target}
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return {"logged_in": has_aws_credentials(), "provider": target}
else:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
except ImportError:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
return {"logged_in": False}
@ -5821,11 +5828,13 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
if auth_mode == "entra_id":
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
installed = has_azure_identity_installed()
entra_cfg = {}
if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict):

View file

@ -549,8 +549,12 @@ def _interactive_auth() -> None:
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
if has_aws_credentials and has_aws_credentials():
auth_source = resolve_aws_auth_env_var() or "unknown"
region = resolve_bedrock_region()
print(f"bedrock (AWS SDK credential chain):")
@ -577,12 +581,12 @@ def _interactive_auth() -> None:
_cfg_provider = str(_model_cfg.get("provider") or "").strip().lower()
_cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower()
if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id":
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
_base_url = str(_model_cfg.get("base_url") or "").strip()
_entra = _model_cfg.get("entra") or {}
if not isinstance(_entra, dict):

View file

@ -1530,12 +1530,14 @@ def run_doctor(args):
return _ConnectivityResult("Anthropic API", [], [])
try:
import httpx
from agent.anthropic_adapter import (
_is_oauth_token,
_COMMON_BETAS,
_OAUTH_ONLY_BETAS,
_CONTEXT_1M_BETA,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
_COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS")
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
_CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA")
if not all([_is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS]):
raise ImportError("anthropic provider services not fully registered")
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(key)
if is_oauth:
@ -1679,11 +1681,13 @@ def run_doctor(args):
def _probe_bedrock() -> _ConnectivityResult:
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region]):
raise ImportError("bedrock provider services not fully registered")
except ImportError:
return _ConnectivityResult("AWS Bedrock", [], [])
if not has_aws_credentials():
@ -1754,12 +1758,14 @@ def run_doctor(args):
return _ConnectivityResult("Azure Foundry (Entra ID)", [], [])
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure_ns.get("describe_active_credential")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
return _ConnectivityResult(
"Azure Foundry (Entra ID)",

View file

@ -4370,7 +4370,9 @@ def _setup_feishu():
if method_idx == 0:
# ── QR scan-to-create ──
try:
from gateway.platforms.feishu import qr_register
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
qr_register = _feishu_entry.helper_functions.get("qr_register") if _feishu_entry else None
except Exception as exc:
print_error(f" Feishu / Lark onboard import failed: {exc}")
qr_register = None
@ -4411,8 +4413,13 @@ def _setup_feishu():
# Try to probe the bot with manual credentials
bot_name = None
try:
from gateway.platforms.feishu import probe_bot
bot_info = probe_bot(app_id, app_secret, domain)
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
probe_bot = _feishu_entry.helper_functions.get("probe_bot") if _feishu_entry else None
if probe_bot:
bot_info = probe_bot(app_id, app_secret, domain)
else:
bot_info = None
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")

View file

@ -589,10 +589,12 @@ def _has_any_provider_configured() -> bool:
# being installed doesn't mean the user wants Hermes to use their tokens.
if _has_hermes_config:
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if read_claude_code_credentials is None or is_claude_code_token_valid is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
if creds and (
@ -4087,13 +4089,15 @@ def _model_flow_azure_foundry(config, current_model=""):
if use_entra:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure.get("build_token_provider")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
if any(v is None for v in [EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure plugin not registered")
except ImportError as exc:
print()
print(f"⚠ Could not import azure-identity adapter: {exc}")
@ -5407,12 +5411,14 @@ def _model_flow_bedrock(config, current_model=""):
# 1. Check for AWS credentials
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
discover_bedrock_models,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
discover_bedrock_models = _bedrock.get("discover_bedrock_models")
if any(v is None for v in [has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models]):
raise ImportError("bedrock plugin not registered")
except ImportError:
print(" ✗ boto3 is not installed. Install it with:")
print(" pip install boto3")
@ -5860,11 +5866,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
def _run_anthropic_oauth_flow(save_env_value):
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
from agent.anthropic_adapter import (
run_oauth_setup_token,
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
run_oauth_setup_token = _anthropic.get("run_oauth_setup_token")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if run_oauth_setup_token is None:
raise ImportError("anthropic plugin not registered")
from hermes_cli.config import (
save_anthropic_oauth_token,
use_anthropic_claude_code_credentials,
@ -5972,11 +5980,13 @@ def _model_flow_anthropic(config, current_model=""):
existing_key = get_anthropic_key()
cc_available = False
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
_is_oauth_token,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
_is_oauth_token = _anthropic.get("_is_oauth_token")
if any(v is None for v in [read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token]):
raise ImportError("anthropic plugin not registered")
cc_creds = read_claude_code_credentials()
if cc_creds and is_claude_code_token_valid(cc_creds):
@ -7977,71 +7987,13 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
def _refresh_active_lazy_features() -> None:
"""Refresh lazy-installed backends after a code update.
"""No-op — lazy deps removed.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
optional backends moved to ``tools/lazy_deps.py`` and only install on
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
leaves those packages untouched so if we bump a pin in
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
activated the backend keep the stale version forever.
This function asks lazy_deps which features the user has previously
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet no churn for cold backends.
Never raises. A failure here must not block the rest of the update.
Optional backends are now proper plugin packages (hermes-agent-anthropic,
hermes-agent-telegram, etc.) installed via extras. ``hermes update``
refreshes them through ``uv pip install -e .[all]`` like any other dep.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
if not active:
return
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
try:
results = lazy_deps.refresh_active_features(prompt=False)
except Exception as exc:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
return
refreshed = [f for f, s in results.items() if s == "refreshed"]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
if refreshed:
print(f"{len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f"{len(current)} already current")
if skipped:
# Most common reason: security.allow_lazy_installs=false. Show one
# line so the user knows why; not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if failed:
for feature, status in failed:
reason = status.split(": ", 1)[-1]
# Clip noisy pip stderr to keep update output legible.
if len(reason) > 200:
reason = reason[:200] + "..."
print(f"{feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
pass
def _install_python_dependencies_with_optional_fallback(

View file

@ -1151,8 +1151,12 @@ def list_authenticated_providers(
if slug_norm != current_norm:
return False
try:
from agent.bedrock_adapter import has_aws_credentials
return bool(has_aws_credentials())
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return bool(has_aws_credentials())
return False
except Exception:
return False
@ -1332,10 +1336,12 @@ def list_authenticated_providers(
# configured.
if not has_creds and hermes_slug == "anthropic":
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic_ns.get("read_claude_code_credentials")
read_hermes_oauth_credentials = _anthropic_ns.get("read_hermes_oauth_credentials")
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
raise ImportError("anthropic credential readers not registered")
hermes_creds = read_hermes_oauth_credentials()
cc_creds = read_claude_code_credentials()
if (hermes_creds and hermes_creds.get("accessToken")) or \
@ -1359,7 +1365,11 @@ def list_authenticated_providers(
# reflects the active region (eu.*, ap.*) not the static us.* list.
elif overlay.auth_type == "aws_sdk":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
if bedrock_model_ids_or_none is None:
raise ImportError("bedrock_model_ids_or_none not found")
_ids = bedrock_model_ids_or_none()
model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, []))
except Exception:
@ -1436,7 +1446,11 @@ def list_authenticated_providers(
# region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list.
if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
if bedrock_model_ids_or_none is None:
raise ImportError("bedrock_model_ids_or_none not found")
_ids = bedrock_model_ids_or_none()
_cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, [])
except Exception:

View file

@ -2284,7 +2284,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
# below — bedrock is not expected to appear in that table.
if normalized == "bedrock":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
if bedrock_model_ids_or_none is None:
raise ImportError("bedrock_model_ids_or_none not found in bedrock provider")
ids = bedrock_model_ids_or_none()
if ids is not None:
return ids
@ -2331,7 +2335,15 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
Claude Code auto-discovery). Returns sorted model IDs or None.
"""
try:
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
_COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS")
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
_CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA")
if resolve_anthropic_token is None or _is_oauth_token is None:
raise ImportError("anthropic provider services not registered")
except ImportError:
return None
@ -2343,8 +2355,8 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
is_oauth = _is_oauth_token(token)
if is_oauth:
headers["Authorization"] = f"Bearer {token}"
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
if _COMMON_BETAS and _OAUTH_ONLY_BETAS:
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = token
@ -3703,7 +3715,12 @@ def validate_requested_model(
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
if normalized == "bedrock":
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
discover_bedrock_models = _bedrock_ns.get("discover_bedrock_models")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if discover_bedrock_models is None or resolve_bedrock_region is None:
raise ImportError("bedrock discovery functions not registered")
region = resolve_bedrock_region()
discovered = discover_bedrock_models(region)
discovered_ids = {m["id"] for m in discovered}

View file

@ -778,6 +778,163 @@ class PluginContext:
name,
)
# -- auth provider registration -------------------------------------------
def register_platform_entry(
self,
name: str,
adapter_class: type,
check_requirements: Callable,
available_flag: str = "",
constants: dict | None = None,
helper_functions: dict | None = None,
) -> None:
"""Register a platform adapter entry in the capability registries.
This populates ``agent.plugin_registries.registries.platform_adapters``
so core code can look up adapter classes, constants, and helper
functions without importing from ``hermes_agent_*`` packages directly.
Call this **in addition to** :meth:`register_platform` the two
registries serve different consumers:
* ``register_platform`` ``gateway.platform_registry`` (gateway
adapter creation, setup wizard, status)
* ``register_platform_entry`` ``agent.plugin_registries`` (adapter
class access, constants, helpers for send_message_tool, etc.)
Args:
name: Platform identifier (e.g. ``"telegram"``).
adapter_class: The adapter class itself (e.g. ``TelegramAdapter``).
check_requirements: Callable returning ``bool`` are deps installed?
available_flag: Name of the module-level AVAILABLE boolean, if any.
constants: Platform-specific constants (e.g.
``{"FEISHU_DOMAIN": ..., "LARK_DOMAIN": ...}``).
helper_functions: Platform-specific helpers (e.g.
``{"_strip_mdv2": _strip_mdv2, "qr_register": qr_register}``).
"""
from agent.plugin_registries import registries, PlatformAdapterEntry
entry = PlatformAdapterEntry(
name=name,
adapter_class=adapter_class,
check_requirements=check_requirements,
available_flag=available_flag,
constants=constants or {},
helper_functions=helper_functions or {},
)
registries.register_platform(entry)
logger.debug(
"Plugin %s registered platform entry: %s",
self.manifest.name,
name,
)
def register_tool_provider_entry(
self,
name: str,
tool_functions: dict | None = None,
check_fn: Callable | None = None,
constants: dict | None = None,
config_functions: dict | None = None,
environment_classes: dict | None = None,
) -> None:
"""Register a tool provider entry in the capability registries.
This populates ``agent.plugin_registries.registries.tool_providers``
so core code can look up tool functions, constants, and config
helpers without importing from ``hermes_agent_*`` packages directly.
Args:
name: Tool identifier (e.g. ``"tts"``, ``"stt"``).
tool_functions: Dict of function name callable
(e.g. ``{"text_to_speech_tool": text_to_speech_tool}``).
check_fn: Optional callable returning ``bool`` are deps
installed and configured?
constants: Tool-specific constants
(e.g. ``{"MAX_FILE_SIZE": 25 * 1024 * 1024}``).
config_functions: Config/utility functions
(e.g. ``{"is_stt_enabled": is_stt_enabled}``).
environment_classes: Environment classes for terminal backends
(e.g. ``{"DaytonaEnvironment": DaytonaEnvironment}``).
"""
from agent.plugin_registries import registries, ToolProviderEntry
entry = ToolProviderEntry(
name=name,
tool_functions=tool_functions or {},
check_fn=check_fn,
constants=constants or {},
config_functions=config_functions or {},
environment_classes=environment_classes or {},
)
registries.register_tool_provider(entry)
logger.debug(
"Plugin %s registered tool provider entry: %s",
self.manifest.name,
name,
)
def register_provider_services(
self,
name: str,
services: dict,
) -> None:
"""Register a namespace dict of provider-specific services.
This is the escape hatch for model-provider plugins that expose many
symbols (anthropic has 50+). Each plugin registers its public surface
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
looks up specific symbols instead of importing from the plugin
package directly.
Args:
name: Provider identifier (e.g. ``"anthropic"``, ``"bedrock"``).
services: Dict of symbol name callable or value.
"""
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
logger.debug(
"Plugin %s registered provider services: %s (%d symbols)",
self.manifest.name,
name,
len(services),
)
def register_auth_provider(
self,
name: str,
provider: Any,
*,
cli_group: str = "",
setup_subcommands: bool = False,
) -> None:
"""Register an authentication provider.
``provider`` must implement the :class:`agent.plugin_registries.AuthProvider`
protocol (``name``, ``has_credentials``, ``check_env_vars``,
``resolve_token``, ``refresh_token``). It may also expose
provider-specific attributes (``_is_oauth_token``,
``_HERMES_OAUTH_FILE``, ``read_claude_code_credentials``, etc.)
that core code accesses via the registry.
Registered providers are queried by core code via
``registries.get_auth_provider(name)`` instead of importing
directly from ``hermes_agent_*`` packages.
"""
from agent.plugin_registries import registries
registries.register_auth_provider(
name, provider,
cli_group=cli_group,
setup_subcommands=setup_subcommands,
)
logger.debug(
"Plugin %s registered auth provider: %s",
self.manifest.name, name,
)
# -- hook registration --------------------------------------------------
# -- auxiliary task registration ---------------------------------------
@ -1034,6 +1191,11 @@ class PluginManager:
)
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
bundled_providers = self._scan_directory(
repo_plugins / "model-providers", source="bundled"
)
logger.debug(" bundled/model-providers: %d manifest(s)", len(bundled_providers))
manifests.extend(bundled_providers)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
@ -1070,7 +1232,16 @@ class PluginManager:
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
winners[manifest.key or manifest.name] = manifest
key = manifest.key or manifest.name
existing = winners.get(key)
# Bundled/workspace plugins take precedence over entry-points
# for the same key — the local source is the one we're
# actively developing; the entry-point is the published
# version. Only let entry-points fill gaps where no bundled
# version exists.
if existing is not None and existing.source == "bundled" and manifest.source != "bundled":
continue
winners[key] = manifest
for manifest in winners.values():
lookup_key = manifest.key or manifest.name
@ -1098,30 +1269,12 @@ class PluginManager:
)
continue
# Model provider plugins are loaded by providers/__init__.py
# (its own lazy discovery keyed off first get_provider_profile()
# call). We record the manifest here for introspection but do
# not import the module — a second import would create two
# ProviderProfile instances and break the "last writer wins"
# override semantics between bundled and user plugins.
if manifest.kind == "model-provider":
loaded = LoadedPlugin(manifest=manifest, enabled=True)
self._plugins[lookup_key] = loaded
logger.debug(
"Skipping '%s' (model-provider, handled by providers/ discovery)",
lookup_key,
)
continue
# Built-in backends auto-load — they ship with hermes and must
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``<category>.provider`` config,
# enforced by the tool wrapper.
#
# Bundled platform plugins (gateway adapters like IRC) auto-load
# for the same reason: every platform Hermes ships must be
# available out of the box without the user having to opt in.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}:
# Model provider plugins auto-load just like backends and
# platforms. They register their provider services (auth,
# transport, metadata) via ctx.register_provider_services()
# in their register() function, which populates the
# capability registries that core code queries.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform", "model-provider"}:
self._load_plugin(manifest)
continue

View file

@ -976,11 +976,13 @@ def _resolve_azure_foundry_runtime(
auth_mode = "api_key"
else:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure_ns.get("build_token_provider")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
raise AuthError(
"Azure Foundry Entra ID auth requires the 'azure-identity' "
@ -1072,7 +1074,11 @@ def _resolve_explicit_runtime(
base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com"
api_key = explicit_api_key
if not api_key:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
api_key = resolve_anthropic_token()
if not api_key:
@ -1512,7 +1518,11 @@ def resolve_runtime_provider(
"config.yaml model section at a custom env var."
)
else:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
token = resolve_anthropic_token()
if not token:
raise AuthError(
@ -1530,12 +1540,14 @@ def resolve_runtime_provider(
# AWS Bedrock (native Converse API via boto3)
if provider == "bedrock":
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
is_anthropic_bedrock_model,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
is_anthropic_bedrock_model = _bedrock_ns.get("is_anthropic_bedrock_model")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, is_anthropic_bedrock_model]):
raise ImportError("bedrock provider services not fully registered")
# When the user explicitly selected bedrock (not auto-detected),
# trust boto3's credential chain — it handles IMDS, ECS task roles,
# Lambda execution roles, SSO, and other implicit sources that our

View file

@ -2186,59 +2186,32 @@ def _setup_matrix():
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
# Use the central lazy-deps feature group so we install ALL of
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
# aiosqlite uninstalled and broke E2EE connect with
# ``No module named 'asyncpg'`` on every fresh install (#31116).
matrix_pkg = "hermes-agent[matrix]"
# Matrix deps are now a proper plugin package. Install it the normal way.
try:
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
_missing_before = feature_missing("platform.matrix")
if _missing_before:
print_info(
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
)
try:
_lazy_ensure("platform.matrix", prompt=False)
print_success(f"{matrix_pkg} installed")
except Exception as exc:
print_warning(
f"Install failed — run manually: pip install "
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
f"aiohttp-socks"
)
print_info(f" Error: {exc}")
__import__("hermes_agent_matrix")
except ImportError:
# tools.lazy_deps unavailable (extreme edge case — partial
# install). Fall back to the legacy single-package install
# path so the wizard still does *something*.
try:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install '{matrix_pkg}'"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print()
print_info("🔒 Security: Restrict who can use your bot")

View file

@ -779,7 +779,9 @@ def speak_text(text: str) -> None:
_debug(f"speak_text: TTS begin (paused_recording={paused_recording})")
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
@ -806,6 +808,8 @@ def speak_text(text: str) -> None:
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
)
if text_to_speech_tool is None:
raise ImportError("TTS plugin not registered")
_debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}")
text_to_speech_tool(text=tts_text, output_path=mp3_path)

View file

@ -58,22 +58,10 @@ try:
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
# First try lazy-installing the dashboard extras. Only the user actually
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
# them out of every other install path. After install, re-import.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.dashboard", prompt=False)
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except Exception:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
)
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
"Install with: pip install 'hermes-agent[dashboard]'"
)
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
@ -1319,11 +1307,13 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
The dashboard reports the highest-priority source that's actually present.
"""
try:
from agent.anthropic_adapter import (
read_hermes_oauth_credentials,
read_claude_code_credentials,
_HERMES_OAUTH_FILE,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_hermes_oauth_credentials = _anthropic.get("read_hermes_oauth_credentials")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if read_hermes_oauth_credentials is None:
raise ImportError("anthropic plugin not registered")
except ImportError:
read_claude_code_credentials = None # type: ignore
read_hermes_oauth_credentials = None # type: ignore
@ -1382,7 +1372,11 @@ def _claude_code_only_status() -> Dict[str, Any]:
when they also have a separate Hermes-managed PKCE login.
"""
try:
from agent.anthropic_adapter import read_claude_code_credentials
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
if read_claude_code_credentials is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
except Exception:
creds = None
@ -1568,8 +1562,10 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
# want to undo a disconnect.
if provider_id in {"anthropic", "claude-code"}:
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is not None and _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
except Exception:
pass
@ -1636,13 +1632,15 @@ _oauth_sessions_lock = threading.Lock()
# Guarded so hermes web still starts if anthropic_adapter is unavailable;
# Phase 2 endpoints will return 501 in that case.
try:
from agent.anthropic_adapter import (
_OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
_OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
_OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
_OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
_generate_pkce as _generate_pkce_pair,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_ANTHROPIC_OAUTH_CLIENT_ID = _anthropic.get("_OAUTH_CLIENT_ID")
_ANTHROPIC_OAUTH_TOKEN_URL = _anthropic.get("_OAUTH_TOKEN_URL")
_ANTHROPIC_OAUTH_REDIRECT_URI = _anthropic.get("_OAUTH_REDIRECT_URI")
_ANTHROPIC_OAUTH_SCOPES = _anthropic.get("_OAUTH_SCOPES")
_generate_pkce_pair = _anthropic.get("_generate_pkce")
if any(v is None for v in [_ANTHROPIC_OAUTH_CLIENT_ID, _ANTHROPIC_OAUTH_TOKEN_URL, _ANTHROPIC_OAUTH_REDIRECT_URI, _ANTHROPIC_OAUTH_SCOPES, _generate_pkce_pair]):
raise ImportError("anthropic plugin not registered")
_ANTHROPIC_OAUTH_AVAILABLE = True
except ImportError:
_ANTHROPIC_OAUTH_AVAILABLE = False
@ -1680,7 +1678,11 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
Mirrors what auth_commands.add_command does so the dashboard flow leaves
the system in the same state as ``hermes auth add anthropic``.
"""
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is None:
raise ImportError("anthropic plugin not registered")
payload = {
"accessToken": access_token,
"refreshToken": refresh_token,

View file

@ -147,8 +147,15 @@ def create_environment(
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
elif env_type == "modal":
from tools.environments.modal import ModalEnvironment
return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
from agent.plugin_registries import registries
_modal = registries.get_tool_provider("modal")
_ModalEnvironment = _modal.environment_classes.get("ModalEnvironment") if _modal else None
if _ModalEnvironment is None:
raise ValueError(
"Modal backend selected but the hermes_agent_modal plugin is not loaded. "
"Ensure the modal plugin is installed and enabled."
)
return _ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
else:
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")

View file

@ -260,6 +260,239 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
# ── Plugin architecture (hermetic core boundary) ───────────────────
#
# These checks prove that under NixOS (sealed venv, no pip install),
# the plugin system works correctly:
# 1. Core never imports from hermes_agent_* packages directly
# 2. Plugin registries are populated after discovery
# 3. Provider service namespaces are queryable
# 4. Optional plugins degrade gracefully (None returns, no crash)
# 5. No ensure() / lazy_deps / pip-install at runtime
# Check 1: Zero direct hermes_agent_* imports in core code
plugin-hermetic-boundary = pkgs.runCommand "hermes-plugin-hermetic-boundary" { } ''
set -e
echo "=== Checking core never imports from plugin packages ==="
# Search for direct imports from hermes_agent_* in core code
# (excluding plugins/, tests/, website/, and comments)
VIOLATIONS=$(${hermesVenv}/bin/python3 -c '
import subprocess, re, sys
result = subprocess.run(
["grep", "-rn",
"from hermes_agent_\\|import hermes_agent_",
"${hermes-agent}/share/hermes-agent"],
capture_output=True, text=True
)
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
# Filter: only .py files, not in plugins/ or tests/, not comments
violations = []
for line in lines:
if not line.endswith(".py"):
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
filepath, lineno, content = parts
# Skip plugin directories
if "/plugins/" in filepath:
continue
# Skip test directories
if "/tests/" in filepath or "/test_" in filepath:
continue
# Skip comments
stripped = content.lstrip()
if stripped.startswith("#"):
continue
violations.append(line)
for v in violations:
print(v)
sys.exit(1 if violations else 0)
' 2>&1 || true)
if [ -n "$VIOLATIONS" ]; then
echo "FAIL: Core code imports directly from plugin packages:"
echo "$VIOLATIONS"
exit 1
fi
echo "PASS: Zero direct hermes_agent_* imports in core"
echo "=== Checking no ensure() / LAZY_DEPS in core ==="
ENSURE_VIOLATIONS=$(grep -rn 'ensure(' ${hermes-agent}/share/hermes-agent/agent/ ${hermes-agent}/share/hermes-agent/hermes_cli/ --include='*.py' 2>/dev/null | grep -v '__pycache__' | grep -v '# ' || true)
if [ -n "$ENSURE_VIOLATIONS" ]; then
echo "FAIL: ensure() still used in core:"
echo "$ENSURE_VIOLATIONS"
exit 1
fi
echo "PASS: No ensure() calls in core code"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 2: Plugin registries populate after discovery
plugin-registries-populate = pkgs.runCommand "hermes-plugin-registries-populate" { } ''
set -e
echo "=== Checking plugin registries populate after discovery ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
out = {
"provider_services": list(registries._provider_services.keys()),
"platform_adapters": list(registries.platform_adapters.keys()),
"tool_providers": list(registries.tool_providers.keys()),
}
json.dump(out, sys.stdout)
' 2>/dev/null)
echo "Registry state: $RESULT"
# Verify provider services populated
PROV_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.provider_services | length')
if [ "$PROV_COUNT" -lt 1 ]; then
echo "FAIL: No provider services registered (expected >= 1)"
exit 1
fi
echo "PASS: $PROV_COUNT provider service(s) registered"
# Verify platform adapters populated
PLAT_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.platform_adapters | length')
if [ "$PLAT_COUNT" -lt 1 ]; then
echo "FAIL: No platform adapters registered (expected >= 1)"
exit 1
fi
echo "PASS: $PLAT_COUNT platform adapter(s) registered"
# Verify tool providers populated
TOOL_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.tool_providers | length')
if [ "$TOOL_COUNT" -lt 1 ]; then
echo "FAIL: No tool providers registered (expected >= 1)"
exit 1
fi
echo "PASS: $TOOL_COUNT tool provider(s) registered"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 3: Specific provider service lookups work
plugin-provider-lookups = pkgs.runCommand "hermes-plugin-provider-lookups" { } ''
set -e
echo "=== Checking provider service lookups ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
checks = {
"anthropic.resolve_anthropic_token": registries.get_provider_service("anthropic", "resolve_anthropic_token") is not None,
"bedrock.has_aws_credentials": registries.get_provider_service("bedrock", "has_aws_credentials") is not None,
"azure.is_token_provider": registries.get_provider_service("azure", "is_token_provider") is not None,
}
json.dump(checks, sys.stdout)
' 2>/dev/null)
echo "Lookup results: $RESULT"
for key in anthropic.resolve_anthropic_token bedrock.has_aws_credentials azure.is_token_provider; do
VALUE=$(echo "$RESULT" | ${pkgs.jq}/bin/jq --arg k "$key" '.[$k]')
if [ "$VALUE" != "true" ]; then
echo "FAIL: $key lookup returned $VALUE (expected true)"
exit 1
fi
echo "PASS: $key lookup works"
done
mkdir -p $out
echo "ok" > $out/result
'';
# Check 4: Missing plugins degrade gracefully (no crash)
plugin-missing-graceful = pkgs.runCommand "hermes-plugin-missing-graceful" { } ''
set -e
echo "=== Checking missing plugins degrade gracefully ==="
export HOME=$(mktemp -d)
${hermesVenv}/bin/python3 -c '
from agent.plugin_registries import registries
# Lookup from non-existent provider — should return None, not crash
result = registries.get_provider_service("nonexistent-provider", "some_function")
assert result is None, f"Expected None for missing provider, got {result}"
# Lookup from empty registry — should return None
result2 = registries.get_provider_namespace("no-such-provider")
assert result2 == {}, f"Expected empty dict for missing namespace, got {result2}"
# Lookup specific tool provider that does not exist
result3 = registries.get_tool_provider("nonexistent-tool")
assert result3 is None, f"Expected None for missing tool provider, got {result3}"
print("PASS: All missing-plugin lookups return None gracefully")
' 2>&1
echo "PASS: Missing plugins degrade gracefully (no crash)"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 5: No runtime pip install / ensure in gateway/run.py
plugin-no-runtime-install = pkgs.runCommand "hermes-plugin-no-runtime-install" { } ''
set -e
echo "=== Checking no runtime pip install / ensure in core ==="
# Check gateway/run.py has no ensure() or pip install
GATEWAY=${hermes-agent}/share/hermes-agent/gateway/run.py
if [ -f "$GATEWAY" ]; then
if grep -q 'ensure(' "$GATEWAY" || grep -q 'pip install' "$GATEWAY"; then
echo "FAIL: gateway/run.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$GATEWAY"
exit 1
fi
echo "PASS: gateway/run.py has no ensure()/pip install"
else
echo "SKIP: gateway/run.py not found in package"
fi
# Check run_agent.py has no ensure() or pip install
RUN_AGENT=${hermes-agent}/share/hermes-agent/run_agent.py
if [ -f "$RUN_AGENT" ]; then
if grep -q 'ensure(' "$RUN_AGENT" || grep -q 'pip install' "$RUN_AGENT"; then
echo "FAIL: run_agent.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$RUN_AGENT"
exit 1
fi
echo "PASS: run_agent.py has no ensure()/pip install"
else
echo "SKIP: run_agent.py not found in package"
fi
# Check tools/lazy_deps.py is gone
LAZY_DEPS=${hermes-agent}/share/hermes-agent/tools/lazy_deps.py
if [ -f "$LAZY_DEPS" ]; then
echo "FAIL: tools/lazy_deps.py still exists should be removed"
exit 1
fi
echo "PASS: tools/lazy_deps.py removed"
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config merge + round-trip test ────────────────────────────────
# Tests the merge script (Nix activation behavior) across 7
# scenarios, then verifies Python's load_config() reads correctly.

View file

@ -4,7 +4,7 @@ let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
hash = "sha256-RPPWPM0nEkwsaQHrkdEP+UMTZ2aF7JHUNfsIEnKt1l8=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View file

@ -0,0 +1,7 @@
"""Bridge module — delegates plugin registration to hermes_agent_dashboard."""
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_dashboard package."""
from hermes_agent_dashboard import register as _inner_register
_inner_register(ctx)

View file

@ -0,0 +1,6 @@
"""Hermes Agent web dashboard."""
def register(ctx):
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
pass

View file

@ -0,0 +1,6 @@
name: dashboard
version: 0.1.0
description: Web dashboard (FastAPI + uvicorn)
kind: backend
provides_tools: ["dashboard"]
provides_hooks: []

View file

@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-dashboard"
version = "0.1.0"
description = "Hermes Agent web dashboard (FastAPI + Uvicorn)"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
]
[project.entry-points."hermes_agent.plugins"]
dashboard = "hermes_agent_dashboard:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_dashboard*"]

View file

@ -0,0 +1,7 @@
"""Bridge module — delegates plugin registration to hermes_agent_fal."""
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_fal package."""
from hermes_agent_fal import register as _inner_register
_inner_register(ctx)

View file

@ -0,0 +1,36 @@
"""hermes-agent-fal: FAL.ai SDK plumbing plugin for Hermes Agent."""
from hermes_agent_fal.fal_common import ( # noqa: F401
import_fal_client,
_ManagedFalSyncClient,
_extract_http_status,
_normalize_fal_queue_url_format,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group.
Registers FAL SDK plumbing (import_fal_client, _ManagedFalSyncClient,
etc.) in the plugin capability registry so core code can look them
up without importing from ``hermes_agent_fal`` directly.
"""
from hermes_agent_fal.fal_common import (
import_fal_client,
_ManagedFalSyncClient,
_extract_http_status,
_normalize_fal_queue_url_format,
)
ctx.register_tool_provider_entry(
name="fal",
tool_functions={
"import_fal_client": import_fal_client,
},
constants={
"_normalize_fal_queue_url_format": _normalize_fal_queue_url_format,
},
config_functions={
"_ManagedFalSyncClient": _ManagedFalSyncClient,
"_extract_http_status": _extract_http_status,
},
)

View file

@ -2,9 +2,8 @@
Holds the stateless atoms that every FAL-backed tool needs:
* :func:`import_fal_client` lazy import + ``lazy_deps`` integration so
``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI
invocation when imported eagerly).
* :func:`import_fal_client` lazy import so ``fal_client`` isn't pulled at
cold start (it added ~64 ms per CLI invocation when imported eagerly).
* :class:`_ManagedFalSyncClient` wrapper that drives a Nous-managed
fal-queue gateway through the standard ``fal_client.SyncClient``
primitives.
@ -31,8 +30,7 @@ from urllib.parse import urlencode
def import_fal_client() -> Any:
"""Import ``fal_client`` (via ``lazy_deps`` when available) and return
the module reference.
"""Import ``fal_client`` and return the module reference.
Callers are responsible for caching the result on their own module
global keeping per-module globals lets tests monkey-patch the
@ -41,13 +39,6 @@ def import_fal_client() -> Any:
Raises :class:`ImportError` if the package is genuinely unavailable.
"""
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("image.fal", prompt=False)
except ImportError:
pass
except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints
raise ImportError(str(exc))
import fal_client # type: ignore # noqa: WPS433 — intentionally lazy
return fal_client

View file

@ -0,0 +1,6 @@
name: fal
version: 0.1.0
description: FAL.ai image generation backend
kind: backend
provides_tools: ["image_gen"]
provides_hooks: []

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-fal"
version = "0.1.0"
description = "FAL.ai SDK plumbing plugin for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"fal-client==0.13.1",
]
[project.entry-points."hermes_agent.plugins"]
fal = "hermes_agent_fal:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_fal*"]

View file

@ -879,8 +879,7 @@ class HindsightMemoryProvider(MemoryProvider):
+ (f": {reason}" if reason else "")
)
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("memory.hindsight", prompt=False)
from hindsight import HindsightEmbedded # noqa: F401 — side-effect import
except ImportError:
pass
except Exception as _e:

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-hindsight"
version = "1.0.0"
description = "Hindsight long-term memory with knowledge graph for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"hindsight-client==0.6.1",
]
[project.entry-points."hermes_agent.plugins"]
hindsight = "hermes_agent_hindsight:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_hindsight*"]

View file

@ -688,23 +688,12 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"For local instances, set HONCHO_BASE_URL instead."
)
# Lazy-install the honcho SDK on demand. ensure() honors
# Import the honcho SDK (installed via hermes-agent-honcho package).
# security.allow_lazy_installs (default true). On failure we surface
# the original ImportError-shape message so existing callers still get
# the "go run hermes honcho setup" hint they used to.
try:
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
_lazy_ensure("memory.honcho", prompt=False)
except ImportError:
# lazy_deps module missing — fall through to the raw import below.
pass
except Exception:
# FeatureUnavailable or unexpected error. Don't crash here; let the
# actual import attempt produce the canonical error message.
pass
try:
from honcho import Honcho
from honcho import Honcho # noqa: F401 — imported for side-effects
except ImportError:
raise ImportError(
"honcho-ai is required for Honcho integration. "

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-honcho"
version = "1.0.0"
description = "Honcho AI-native memory for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"honcho-ai==2.0.1",
]
[project.entry-points."hermes_agent.plugins"]
honcho = "hermes_agent_honcho:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_honcho*"]

View file

@ -41,3 +41,9 @@ vercel = VercelAIGatewayProfile(
)
register_provider(vercel)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -19,3 +19,9 @@ alibaba_coding_plan = ProviderProfile(
)
register_provider(alibaba_coding_plan)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -11,3 +11,9 @@ alibaba = ProviderProfile(
)
register_provider(alibaba)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -50,3 +50,9 @@ anthropic = AnthropicProfile(
)
register_provider(anthropic)
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_anthropic package."""
from hermes_agent_anthropic import register as _inner_register
_inner_register(ctx)

View file

@ -0,0 +1,178 @@
"""hermes-agent-anthropic: Anthropic Messages API adapter for Hermes Agent."""
from hermes_agent_anthropic.adapter import ( # noqa: F401
ADAPTIVE_EFFORT_MAP,
THINKING_BUDGET,
_ANTHROPIC_DEFAULT_OUTPUT_LIMIT,
_ANTHROPIC_OUTPUT_LIMITS,
_ADAPTIVE_THINKING_SUBSTRINGS,
_CLAUDE_CODE_SYSTEM_PREFIX,
_CLAUDE_CODE_VERSION_FALLBACK,
_COMMON_BETAS,
_CONTEXT_1M_BETA,
_FAST_MODE_BETA,
_FAST_MODE_SUPPORTED_SUBSTRINGS,
_HERMES_OAUTH_FILE,
_KIMI_FAMILY_MODEL_PREFIXES,
_MCP_TOOL_PREFIX,
_NO_SAMPLING_PARAMS_SUBSTRINGS,
_OAUTH_CLIENT_ID,
_OAUTH_ONLY_BETAS,
_OAUTH_REDIRECT_URI,
_OAUTH_SCOPES,
_OAUTH_TOKEN_URL,
_TOOL_STREAMING_BETA,
_XHIGH_EFFORT_SUBSTRINGS,
_build_anthropic_client_with_bearer_hook,
_common_betas_for_base_url,
_content_parts_to_anthropic_blocks,
_convert_assistant_message,
_convert_content_part_to_anthropic,
_convert_content_to_anthropic,
_convert_tool_message_to_result,
_convert_user_message,
_detect_claude_code_version,
_evict_old_screenshots,
_extract_preserved_thinking_blocks,
_forbids_sampling_params,
_generate_pkce,
_get_anthropic_max_output,
_get_anthropic_sdk,
_get_claude_code_version,
_image_source_from_openai_url,
_is_azure_anthropic_endpoint,
_is_bedrock_model_id,
_is_deepseek_anthropic_endpoint,
_is_kimi_coding_endpoint,
_is_kimi_family_endpoint,
_is_minimax_anthropic_endpoint,
_is_oauth_token,
_is_third_party_anthropic_endpoint,
_manage_thinking_signatures,
_merge_consecutive_roles,
_model_name_is_kimi_family,
_normalize_base_url_text,
_normalize_tool_input_schema,
_prefer_refreshable_claude_code_token,
_read_claude_code_credentials_from_keychain,
_refresh_oauth_token,
_requires_bearer_auth,
_resolve_anthropic_messages_max_tokens,
_resolve_claude_code_token_from_credentials,
_resolve_positive_anthropic_max_tokens,
_sanitize_tool_id,
_strip_orphaned_tool_blocks,
_supports_adaptive_thinking,
_supports_fast_mode,
_supports_xhigh_effort,
_write_claude_code_credentials,
_base_url_needs_context_1m_beta,
build_anthropic_bedrock_client,
build_anthropic_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
is_claude_code_token_valid,
normalize_model_name,
read_claude_code_credentials,
read_claude_managed_key,
read_hermes_oauth_credentials,
refresh_anthropic_oauth_pure,
resolve_anthropic_token,
run_hermes_oauth_login_pure,
run_oauth_setup_token,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group."""
from hermes_agent_anthropic import adapter
# Register every public symbol that __init__.py re-exports from adapter
# as a provider service so core code can look them up via the registry
# instead of importing from hermes_agent_anthropic directly.
_symbols = [
"ADAPTIVE_EFFORT_MAP",
"THINKING_BUDGET",
"_ANTHROPIC_DEFAULT_OUTPUT_LIMIT",
"_ANTHROPIC_OUTPUT_LIMITS",
"_ADAPTIVE_THINKING_SUBSTRINGS",
"_CLAUDE_CODE_SYSTEM_PREFIX",
"_CLAUDE_CODE_VERSION_FALLBACK",
"_COMMON_BETAS",
"_CONTEXT_1M_BETA",
"_FAST_MODE_BETA",
"_FAST_MODE_SUPPORTED_SUBSTRINGS",
"_HERMES_OAUTH_FILE",
"_KIMI_FAMILY_MODEL_PREFIXES",
"_MCP_TOOL_PREFIX",
"_NO_SAMPLING_PARAMS_SUBSTRINGS",
"_OAUTH_CLIENT_ID",
"_OAUTH_ONLY_BETAS",
"_OAUTH_REDIRECT_URI",
"_OAUTH_SCOPES",
"_OAUTH_TOKEN_URL",
"_TOOL_STREAMING_BETA",
"_XHIGH_EFFORT_SUBSTRINGS",
"_build_anthropic_client_with_bearer_hook",
"_common_betas_for_base_url",
"_content_parts_to_anthropic_blocks",
"_convert_assistant_message",
"_convert_content_part_to_anthropic",
"_convert_content_to_anthropic",
"_convert_tool_message_to_result",
"_convert_user_message",
"_detect_claude_code_version",
"_evict_old_screenshots",
"_extract_preserved_thinking_blocks",
"_forbids_sampling_params",
"_generate_pkce",
"_get_anthropic_max_output",
"_get_anthropic_sdk",
"_get_claude_code_version",
"_image_source_from_openai_url",
"_is_azure_anthropic_endpoint",
"_is_bedrock_model_id",
"_is_deepseek_anthropic_endpoint",
"_is_kimi_coding_endpoint",
"_is_kimi_family_endpoint",
"_is_minimax_anthropic_endpoint",
"_is_oauth_token",
"_is_third_party_anthropic_endpoint",
"_manage_thinking_signatures",
"_merge_consecutive_roles",
"_model_name_is_kimi_family",
"_normalize_base_url_text",
"_normalize_tool_input_schema",
"_prefer_refreshable_claude_code_token",
"_read_claude_code_credentials_from_keychain",
"_refresh_oauth_token",
"_requires_bearer_auth",
"_resolve_anthropic_messages_max_tokens",
"_resolve_claude_code_token_from_credentials",
"_resolve_positive_anthropic_max_tokens",
"_sanitize_tool_id",
"_strip_orphaned_tool_blocks",
"_supports_adaptive_thinking",
"_supports_fast_mode",
"_supports_xhigh_effort",
"_write_claude_code_credentials",
"_base_url_needs_context_1m_beta",
"build_anthropic_bedrock_client",
"build_anthropic_client",
"build_anthropic_kwargs",
"convert_messages_to_anthropic",
"convert_tools_to_anthropic",
"is_claude_code_token_valid",
"normalize_model_name",
"read_claude_code_credentials",
"read_claude_managed_key",
"read_hermes_oauth_credentials",
"refresh_anthropic_oauth_pure",
"resolve_anthropic_token",
"run_hermes_oauth_login_pure",
"run_oauth_setup_token",
]
ctx.register_provider_services("anthropic", {
name: getattr(adapter, name) for name in _symbols
})

View file

@ -38,14 +38,6 @@ def _get_anthropic_sdk():
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
global _anthropic_sdk
if _anthropic_sdk is ...:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("provider.anthropic", prompt=False)
except ImportError:
pass
except Exception:
# FeatureUnavailable — fall through to ImportError handling below
pass
try:
import anthropic as _sdk
_anthropic_sdk = _sdk
@ -599,7 +591,7 @@ def _build_anthropic_client_with_bearer_hook(
normalize_proxy_env_vars()
from httpx import Timeout
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0)

View file

@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-anthropic"
version = "0.1.0"
description = "Anthropic Messages API adapter for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"anthropic==0.87.0",
"hermes-agent-azure",
]
[project.entry-points."hermes_agent.plugins"]
anthropic = "hermes_agent_anthropic:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_anthropic*"]

View file

@ -0,0 +1,108 @@
"""Shared fixtures for anthropic plugin tests.
Registers the anthropic plugin in the singleton registry before each test
and provides the ``agent`` fixture used by integration tests.
"""
from unittest.mock import MagicMock, patch
import pytest
class _MinimalCtx:
"""Minimal plugin context that only wires up provider_services."""
def register_provider_services(self, name, services):
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
# No-ops for all other register_* methods so plugins don't crash.
def register_platform(self, *a, **kw): pass
def register_tool_provider_entry(self, *a, **kw): pass
def register_auth_provider(self, *a, **kw): pass
def register_transport_builder(self, *a, **kw): pass
def register_model_metadata_provider(self, *a, **kw): pass
def register_credential_pool(self, *a, **kw): pass
def register_browser_provider(self, *a, **kw): pass
def register_image_gen_provider(self, *a, **kw): pass
def register_video_gen_provider(self, *a, **kw): pass
@pytest.fixture(autouse=True)
def _register_anthropic_plugin():
"""Register the real anthropic plugin for the duration of each test,
then restore the registry to its prior state afterwards.
Uses patch.dict so the registry is guaranteed to be restored even if
tests run across conftest scopes in the same process.
"""
from unittest.mock import patch
from agent.plugin_registries import registries
# Build a fresh real-plugin namespace by calling register() against a
# collector context, then inject it via patch.dict for isolation.
collected: dict = {}
class _CollectCtx:
def register_provider_services(self, name, services):
if name == "anthropic":
# Go through the real register_provider_services so _LazyRef
# wrappers are created. This makes patch("hermes_agent_anthropic.adapter.X")
# work in plugin tests (the _LazyRef re-reads from the module at call time).
registries.register_provider_services(name, services)
collected.update(registries._provider_services.get(name, {}))
def register_platform(self, *a, **kw): pass
def register_tool_provider_entry(self, *a, **kw): pass
def register_auth_provider(self, *a, **kw): pass
def register_transport_builder(self, *a, **kw): pass
def register_model_metadata_provider(self, *a, **kw): pass
def register_credential_pool(self, *a, **kw): pass
def register_browser_provider(self, *a, **kw): pass
def register_image_gen_provider(self, *a, **kw): pass
def register_video_gen_provider(self, *a, **kw): pass
try:
from hermes_agent_anthropic import register as _reg # type: ignore[import]
_reg(_CollectCtx())
except ImportError:
pass
with patch.dict(registries._provider_services, {"anthropic": collected}):
yield
def _make_tool_defs(*names: str) -> list:
"""Build minimal tool definition list accepted by AIAgent.__init__."""
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]
@pytest.fixture()
def agent():
"""Minimal AIAgent with mocked OpenAI client and tool loading."""
from run_agent import AIAgent
with (
patch(
"run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")
),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
a = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
a.client = MagicMock()
return a

View file

@ -9,7 +9,7 @@ from unittest.mock import patch, MagicMock
import pytest
from agent.prompt_caching import apply_anthropic_cache_control
from agent.anthropic_adapter import (
from hermes_agent_anthropic.adapter import (
_is_azure_anthropic_endpoint,
_is_oauth_token,
_refresh_oauth_token,
@ -60,7 +60,7 @@ class TestIsOAuthToken:
class TestBuildAnthropicClient:
def test_setup_token_uses_auth_token(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-oat01-" + "x" * 60)
kwargs = mock_sdk.Anthropic.call_args[1]
assert "auth_token" in kwargs
@ -77,7 +77,7 @@ class TestBuildAnthropicClient:
def test_oauth_drop_context_1m_beta_strips_only_1m(self):
"""drop_context_1m_beta=True strips context-1m-2025-08-07 while
preserving every other OAuth-relevant beta."""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"sk-ant-oat01-" + "x" * 60,
drop_context_1m_beta=True,
@ -92,7 +92,7 @@ class TestBuildAnthropicClient:
assert "fine-grained-tool-streaming-2025-05-14" in betas
def test_api_key_uses_api_key(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-api03-something")
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["api_key"] == "sk-ant-api03-something"
@ -105,7 +105,7 @@ class TestBuildAnthropicClient:
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
def test_custom_base_url(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com")
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["base_url"] == "https://custom.api.com"
@ -114,7 +114,7 @@ class TestBuildAnthropicClient:
}
def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-key",
base_url="https://example.services.ai.azure.com/models/anthropic",
@ -138,7 +138,7 @@ class TestBuildAnthropicClient:
) is False
def test_bedrock_client_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
mock_sdk.AnthropicBedrock = MagicMock()
build_anthropic_bedrock_client("us-east-1")
kwargs = mock_sdk.AnthropicBedrock.call_args[1]
@ -146,7 +146,7 @@ class TestBuildAnthropicClient:
assert "context-1m-2025-08-07" in betas
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"minimax-secret-123",
base_url="https://api.minimax.io/anthropic",
@ -159,7 +159,7 @@ class TestBuildAnthropicClient:
}
def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"minimax-cn-secret-123",
base_url="https://api.minimaxi.com/anthropic",
@ -178,7 +178,7 @@ class TestBuildAnthropicClient:
and the endpoint returns HTTP 401. Also verifies that Azure retains the
1M-context beta even though it now matches `_requires_bearer_auth`.
"""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-foundry-secret-123",
base_url="https://my-resource.openai.azure.com/anthropic",
@ -197,7 +197,7 @@ class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
"hermes_agent_anthropic.adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)
@ -211,7 +211,7 @@ class TestReadClaudeCodeCredentials:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "sk-ant-oat01-token"
@ -221,20 +221,20 @@ class TestReadClaudeCodeCredentials:
def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch):
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds is None
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
def test_returns_none_for_missing_oauth_key(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({"someOtherKey": {}}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
def test_returns_none_for_empty_access_token(self, tmp_path, monkeypatch):
@ -243,7 +243,7 @@ class TestReadClaudeCodeCredentials:
cred_file.write_text(json.dumps({
"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
@ -266,7 +266,7 @@ class TestResolveAnthropicToken:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path):
@ -274,7 +274,7 @@ class TestResolveAnthropicToken:
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
(tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
@ -282,28 +282,28 @@ class TestResolveAnthropicToken:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-api03-mykey"
def test_falls_back_to_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_returns_none_with_no_creds(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
def test_falls_back_to_claude_code_oauth_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-test-token")
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-test-token"
def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path):
@ -319,7 +319,7 @@ class TestResolveAnthropicToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):
@ -335,7 +335,7 @@ class TestResolveAnthropicToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
@ -345,7 +345,7 @@ class TestResolveAnthropicToken:
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-static-token"
@ -356,7 +356,7 @@ class TestRefreshOauthToken:
assert _refresh_oauth_token(creds) is None
def test_successful_refresh(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = {
"accessToken": "old-token",
@ -401,7 +401,7 @@ class TestRefreshOauthToken:
class TestWriteClaudeCodeCredentials:
def test_writes_new_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
assert cred_file.exists()
@ -411,7 +411,7 @@ class TestWriteClaudeCodeCredentials:
assert data["claudeAiOauth"]["expiresAt"] == 12345
def test_preserves_existing_fields(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
cred_dir = tmp_path / ".claude"
cred_dir.mkdir()
cred_file = cred_dir / ".credentials.json"
@ -431,7 +431,7 @@ class TestWriteClaudeCodeCredentials:
the fix shipped in #19673 (google_oauth) and #21148 (mcp_oauth).
"""
import stat as _stat
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
@ -457,10 +457,10 @@ class TestResolveWithRefresh:
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
# Mock refresh to succeed
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
@ -479,9 +479,9 @@ class TestResolveWithRefresh:
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
@ -509,7 +509,7 @@ class TestRunOauthSetupToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@ -527,7 +527,7 @@ class TestRunOauthSetupToken:
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "from-env-var")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@ -540,7 +540,7 @@ class TestRunOauthSetupToken:
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@ -1187,14 +1187,14 @@ class TestBuildAnthropicKwargs:
# Because build_anthropic_kwargs doesn't currently accept sampling
# params through its signature, we exercise the strip behavior by
# calling the internal predicate directly.
from agent.anthropic_adapter import _forbids_sampling_params
from hermes_agent_anthropic.adapter import _forbids_sampling_params
assert _forbids_sampling_params("claude-opus-4-7") is True
assert _forbids_sampling_params("claude-opus-4-6") is False
assert _forbids_sampling_params("claude-sonnet-4-5") is False
def test_supports_fast_mode_predicate(self):
"""Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded."""
from agent.anthropic_adapter import _supports_fast_mode
from hermes_agent_anthropic.adapter import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
@ -1347,36 +1347,36 @@ class TestBuildAnthropicKwargs:
class TestGetAnthropicMaxOutput:
def test_opus_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6") == 128_000
def test_opus_4_6_variant(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6:1m:fast") == 128_000
def test_sonnet_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
def test_sonnet_4_date_stamped(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-20250514") == 64_000
def test_claude_3_5_sonnet(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
def test_claude_3_opus(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-opus-20240229") == 4_096
def test_unknown_future_model(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-ultra-5-20260101") == 128_000
def test_longest_prefix_wins(self):
"""'claude-3-5-sonnet' should match before 'claude-3-5'."""
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
# claude-3-5-sonnet (8192) should win over a hypothetical shorter match
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
@ -1873,7 +1873,7 @@ class TestToolChoice:
# max_tokens resolver — openclaw/openclaw#66664 port
# ---------------------------------------------------------------------------
from agent.anthropic_adapter import (
from hermes_agent_anthropic.adapter import (
_resolve_positive_anthropic_max_tokens,
_resolve_anthropic_messages_max_tokens,
)

View file

@ -0,0 +1,420 @@
"""Integration tests for Anthropic-specific AIAgent behaviour.
Tests that exercise the interaction between AIAgent and the Anthropic
provider plugin covering max_tokens passthrough, image fallback,
provider fallback routing, base-url passthrough, credential refresh,
and OAuth flag setting.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hermes_agent_anthropic.adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
import run_agent
from run_agent import AIAgent
def _make_tool_defs(*names: str) -> list:
"""Build minimal tool definition list accepted by AIAgent.__init__."""
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]
class TestBuildApiKwargsAnthropicMaxTokens:
"""Bug fix: max_tokens was always None for Anthropic mode, ignoring user config."""
def test_max_tokens_passed_to_anthropic(self, agent):
agent.api_mode = "anthropic_messages"
agent.max_tokens = 4096
agent.reasoning_config = None
with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build:
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs([{"role": "user", "content": "test"}])
_, kwargs = mock_build.call_args
if not kwargs:
kwargs = dict(zip(
["model", "messages", "tools", "max_tokens", "reasoning_config"],
mock_build.call_args[0],
))
assert kwargs.get("max_tokens") == 4096 or mock_build.call_args[1].get("max_tokens") == 4096
def test_max_tokens_none_when_unset(self, agent):
agent.api_mode = "anthropic_messages"
agent.max_tokens = None
agent.reasoning_config = None
with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build:
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 16384}
agent._build_api_kwargs([{"role": "user", "content": "test"}])
call_args = mock_build.call_args
# max_tokens should be None (let adapter use its default)
if call_args[1]:
assert call_args[1].get("max_tokens") is None
else:
assert call_args[0][3] is None
class TestAnthropicImageFallback:
def test_build_api_kwargs_converts_multimodal_user_image_to_text(self, agent):
agent.api_mode = "anthropic_messages"
agent.reasoning_config = None
api_messages = [{
"role": "user",
"content": [
{"type": "text", "text": "Can you see this now?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
],
}]
with (
patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=json.dumps({"success": True, "analysis": "A cat sitting on a chair."}))),
patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build,
):
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs(api_messages)
kwargs = mock_build.call_args.kwargs or dict(zip(
["model", "messages", "tools", "max_tokens", "reasoning_config"],
mock_build.call_args.args,
))
transformed = kwargs["messages"]
assert isinstance(transformed[0]["content"], str)
assert "A cat sitting on a chair." in transformed[0]["content"]
assert "Can you see this now?" in transformed[0]["content"]
assert "vision_analyze with image_url: https://example.com/cat.png" in transformed[0]["content"]
def test_build_api_kwargs_reuses_cached_image_analysis_for_duplicate_images(self, agent):
agent.api_mode = "anthropic_messages"
agent.reasoning_config = None
data_url = "data:image/png;base64,QUFBQQ=="
api_messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "first"},
{"type": "input_image", "image_url": data_url},
],
},
{
"role": "user",
"content": [
{"type": "text", "text": "second"},
{"type": "input_image", "image_url": data_url},
],
},
]
mock_vision = AsyncMock(return_value=json.dumps({"success": True, "analysis": "A small test image."}))
with (
patch("tools.vision_tools.vision_analyze_tool", new=mock_vision),
patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build,
):
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs(api_messages)
assert mock_vision.await_count == 1
class TestFallbackAnthropicProvider:
"""Bug fix: _try_activate_fallback had no case for anthropic provider."""
def test_fallback_to_anthropic_sets_api_mode(self, agent):
agent._fallback_activated = False
agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"}
agent._fallback_chain = [agent._fallback_model]
agent._fallback_index = 0
mock_client = MagicMock()
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "***"
with (
patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)),
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build,
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=None),
):
mock_build.return_value = MagicMock()
result = agent._try_activate_fallback()
assert result is True
assert agent.api_mode == "anthropic_messages"
assert agent._anthropic_client is not None
assert agent.client is None
def test_fallback_to_anthropic_enables_prompt_caching(self, agent):
agent._fallback_activated = False
agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"}
agent._fallback_chain = [agent._fallback_model]
agent._fallback_index = 0
mock_client = MagicMock()
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "***"
with (
patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=None),
):
agent._try_activate_fallback()
assert agent._use_prompt_caching is True
def test_fallback_to_openrouter_uses_openai_client(self, agent):
agent._fallback_activated = False
agent._fallback_model = {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}
agent._fallback_chain = [agent._fallback_model]
agent._fallback_index = 0
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-or-test"
with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)):
result = agent._try_activate_fallback()
assert result is True
assert agent.api_mode == "chat_completions"
assert agent.client is mock_client
class TestAnthropicBaseUrlPassthrough:
"""Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies."""
def test_custom_proxy_base_url_passed_through(self):
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build,
):
mock_build.return_value = MagicMock()
a = AIAgent(
api_key="sk-ant...7890",
base_url="https://llm-proxy.company.com/v1",
api_mode="anthropic_messages",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
call_args = mock_build.call_args
# base_url should be passed through, not filtered out
assert call_args[0][1] == "https://llm-proxy.company.com/v1"
def test_none_base_url_passed_as_none(self):
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build,
):
mock_build.return_value = MagicMock()
a = AIAgent(
api_key="sk-ant...7890",
api_mode="anthropic_messages",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
call_args = mock_build.call_args
# No base_url provided, should be default empty string or None
passed_url = call_args[0][1]
assert not passed_url or passed_url is None
class TestAnthropicCredentialRefresh:
def test_try_refresh_anthropic_client_credentials_rebuilds_client(self):
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build,
):
old_client = MagicMock()
new_client = MagicMock()
mock_build.side_effect = [old_client, new_client]
agent = AIAgent(
api_key="sk-ant...oken",
base_url="https://openrouter.ai/api/v1",
api_mode="anthropic_messages",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent._anthropic_client = old_client
agent._anthropic_api_key = "sk-ant...old-token" # differs from what resolve returns
agent._anthropic_base_url = "https://api.anthropic.com"
agent.provider = "anthropic"
with (
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=new_client) as rebuild,
):
assert agent._try_refresh_anthropic_client_credentials() is True
old_client.close.assert_called_once()
rebuild.assert_called_once_with(
"sk-ant...oken", "https://api.anthropic.com", timeout=None,
)
assert agent._anthropic_client is new_client
assert agent._anthropic_api_key == "sk-ant...oken"
def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self):
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
):
agent = AIAgent(
api_key="sk-ant...oken",
base_url="https://openrouter.ai/api/v1",
api_mode="anthropic_messages",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
old_client = MagicMock()
agent._anthropic_client = old_client
agent._anthropic_api_key = "sk-ant...oken"
with (
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"),
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as rebuild,
):
assert agent._try_refresh_anthropic_client_credentials() is False
old_client.close.assert_not_called()
rebuild.assert_not_called()
def test_anthropic_messages_create_preflights_refresh(self):
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
):
agent = AIAgent(
api_key="sk-ant...oken",
base_url="https://openrouter.ai/api/v1",
api_mode="anthropic_messages",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
response = SimpleNamespace(content=[])
agent._anthropic_client = MagicMock()
agent._anthropic_client.messages.create.return_value = response
with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh:
result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"})
refresh.assert_called_once_with()
agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514")
assert result is response
class TestFallbackSetsOAuthFlag:
"""_try_activate_fallback must set _is_anthropic_oauth for Anthropic fallbacks."""
def test_fallback_to_anthropic_oauth_sets_flag(self, agent):
agent._fallback_activated = False
agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"}
agent._fallback_chain = [agent._fallback_model]
agent._fallback_index = 0
mock_client = MagicMock()
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-setup-oauth-token"
with (
patch("agent.auxiliary_client.resolve_provider_client",
return_value=(mock_client, None)),
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=None),
):
result = agent._try_activate_fallback()
assert result is True
assert agent._is_anthropic_oauth is True
def test_fallback_to_anthropic_api_key_clears_flag(self, agent):
agent._fallback_activated = False
agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"}
agent._fallback_chain = [agent._fallback_model]
agent._fallback_index = 0
mock_client = MagicMock()
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-api03-regular-key"
with (
patch("agent.auxiliary_client.resolve_provider_client",
return_value=(mock_client, None)),
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=None),
):
result = agent._try_activate_fallback()
assert result is True
assert agent._is_anthropic_oauth is False
class TestOAuthFlagAfterCredentialRefresh:
"""_is_anthropic_oauth must update when token type changes during refresh."""
def test_oauth_flag_updates_api_key_to_oauth(self, agent):
"""Refreshing from API key to OAuth token must set flag to True."""
from agent.plugin_registries import registries
agent.api_mode = "anthropic_messages"
agent.provider = "anthropic"
agent._anthropic_api_key = "***"
agent._anthropic_client = MagicMock()
agent._is_anthropic_oauth = False
with patch.dict(registries._provider_services, {"anthropic": {
"resolve_anthropic_token": MagicMock(return_value="sk-ant...oken"),
"build_anthropic_client": MagicMock(return_value=MagicMock()),
"_is_oauth_token": MagicMock(return_value=True),
}}):
result = agent._try_refresh_anthropic_client_credentials()
assert result is True
assert agent._is_anthropic_oauth is True
def test_oauth_flag_updates_oauth_to_api_key(self, agent):
"""Refreshing from OAuth to API key must set flag to False."""
from agent.plugin_registries import registries
agent.api_mode = "anthropic_messages"
agent.provider = "anthropic"
agent._anthropic_api_key = "***"
agent._anthropic_client = MagicMock()
agent._is_anthropic_oauth = True
with patch.dict(registries._provider_services, {"anthropic": {
"resolve_anthropic_token": MagicMock(return_value="sk-ant...-key"),
"build_anthropic_client": MagicMock(return_value=MagicMock()),
"_is_oauth_token": MagicMock(return_value=False),
}}):
result = agent._try_refresh_anthropic_client_credentials()
assert result is True
assert agent._is_anthropic_oauth is False

View file

@ -0,0 +1,98 @@
"""Anthropic-specific auth command tests moved from tests/hermes_cli/test_auth_commands.py."""
from __future__ import annotations
import base64
import json
import pytest
def _write_auth_store(tmp_path, payload: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
def _jwt_with_email(email: str) -> str:
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(
json.dumps({"email": email}).encode()
).rstrip(b"=").decode()
return f"{header}.{payload}.signature"
@pytest.fixture(autouse=True)
def _clear_provider_env(monkeypatch):
for key in (
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
):
monkeypatch.delenv(key, raising=False)
def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
token = _jwt_with_email("claude@example.com")
monkeypatch.setattr(
"hermes_agent_anthropic.adapter.run_hermes_oauth_login_pure",
lambda: {
"access_token": token,
"refresh_token": "refresh-token",
"expires_at_ms": 1711234567000,
},
)
from hermes_cli.auth_commands import auth_add_command
class _Args:
provider = "anthropic"
auth_type = "oauth"
api_key = None
label = None
auth_add_command(_Args())
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["anthropic"]
entry = next(item for item in entries if item["source"] == "manual:hermes_pkce")
assert entry["label"] == "claude@example.com"
assert entry["source"] == "manual:hermes_pkce"
assert entry["refresh_token"] == "refresh-token"
assert entry["expires_at_ms"] == 1711234567000
def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch):
"""anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}}))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"anthropic": ["hermes_pkce"]},
}))
# Stub the readers so only hermes_pkce is "available"; claude_code returns None
import hermes_agent_anthropic as aa
monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: {
"accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000,
})
monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None)
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("anthropic", entries)
# hermes_pkce suppressed, claude_code returns None → nothing should be seeded
assert entries == []
assert "hermes_pkce" not in active

View file

@ -0,0 +1,533 @@
"""Tests for Anthropic-specific auxiliary client behaviour.
Covers:
- OAuth vs API-key flag propagation (_try_anthropic AnthropicAuxiliaryClient)
- explicit_api_key propagation through resolve_provider_client _try_anthropic
- Expired Codex token fallback to Anthropic
- Vision client fallback with Anthropic
- Auth refresh retry for Anthropic clients
"""
import json
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from agent.auxiliary_client import (
_try_anthropic,
AnthropicAuxiliaryClient,
resolve_provider_client,
_read_codex_access_token,
_resolve_auto,
get_available_vision_backends,
call_llm,
async_call_llm,
)
class TestAnthropicOAuthFlag:
"""Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client."""
def test_oauth_token_sets_flag(self, monkeypatch):
"""OAuth tokens (sk-ant-oat01-*) should create client with is_oauth=True."""
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token")
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
client, model = _try_anthropic()
assert client is not None
assert isinstance(client, AnthropicAuxiliaryClient)
# The adapter inside should have is_oauth=True
adapter = client.chat.completions
assert adapter._is_oauth is True
def test_api_key_no_oauth_flag(self, monkeypatch):
"""Regular API keys (sk-ant-api-*) should create client with is_oauth=False."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
client, model = _try_anthropic()
assert client is not None
assert isinstance(client, AnthropicAuxiliaryClient)
adapter = client.chat.completions
assert adapter._is_oauth is False
def test_pool_entry_takes_priority_over_legacy_resolution(self):
class _Entry:
access_token = "sk-ant-oat01-pooled"
base_url = "https://api.anthropic.com"
class _Pool:
def has_credentials(self):
return True
def select(self):
return _Entry()
with (
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", side_effect=AssertionError("legacy path should not run")),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()) as mock_build,
):
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic()
assert client is not None
assert model == "claude-haiku-4-5-20251001"
assert mock_build.call_args.args[0] == "sk-ant-oat01-pooled"
class TestAnthropicExplicitApiKey:
"""Test that explicit_api_key is correctly propagated to _try_anthropic().
Parity with the OpenRouter fix in #18768: resolve_provider_client() passes
explicit_api_key to _try_openrouter(), but the anthropic branch was not
updated _try_anthropic() always fell back to resolve_anthropic_token()
even when an explicit key was supplied (e.g. from a fallback_model entry).
"""
def test_try_anthropic_uses_explicit_api_key_over_env(self):
"""_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic("explicit-pool-key")
assert client is not None
assert mock_build.call_args.args[0] == "explicit-pool-key", (
f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}"
)
assert mock_build.call_args.args[0] != "env-fallback-key"
def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self):
"""Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic()
assert client is not None
assert mock_build.call_args.args[0] == "env-fallback-key"
def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self):
"""resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = resolve_provider_client(
provider="anthropic",
explicit_api_key="explicit-fallback-key",
)
assert client is not None
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
)
class TestExpiredCodexFallback:
"""Test that expired Codex tokens don't block the auto chain."""
def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch):
"""When Codex token is expired, auto chain should skip it and try next provider."""
import base64
import time as _time
# Expired Codex JWT
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
expired_jwt = f"{header}.{payload}.fakesig"
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Set up Anthropic as fallback
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant...back")
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
client, model = _resolve_auto()
# Should NOT be Codex, should be Anthropic (or another available provider)
assert not isinstance(client, type(None)), "Should find a provider after expired Codex"
def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch):
"""With expired Codex + OpenRouter key, OpenRouter should win (1st in chain)."""
import base64
import time as _time
# Belt-and-suspenders: _try_openrouter marks openrouter unhealthy
# when OPENROUTER_API_KEY is absent (which the preceding test in
# this class exercises). The file-level _clean_env autouse fixture
# clears the cache, but fixture ordering with the conftest
# _hermetic_environment autouse can leave a narrow window where
# the mark reappears. Explicitly clear here so this test is
# independent of run order.
import agent.auxiliary_client as _aux_mod
_aux_mod._aux_unhealthy_until.clear()
_aux_mod._aux_unhealthy_logged_at.clear()
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
expired_jwt = f"{header}.{payload}.fakesig"
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
client, model = _resolve_auto()
assert client is not None
# OpenRouter is 1st in chain, should win
mock_openai.assert_called()
def test_expired_codex_custom_endpoint_wins(self, tmp_path, monkeypatch):
"""With expired Codex + custom endpoint (Ollama), custom should win (3rd in chain)."""
import base64
import time as _time
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
expired_jwt = f"{header}.{payload}.fakesig"
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Simulate Ollama or custom endpoint
with patch("agent.auxiliary_client._resolve_custom_runtime",
return_value=("http://localhost:11434/v1", "sk-dummy")):
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
client, model = _resolve_auto()
assert client is not None
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
# Mock resolve_anthropic_token to return an OAuth-style token
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = _try_anthropic()
assert client is not None, "Should resolve token"
adapter = client.chat.completions
assert adapter._is_oauth is True, "Non-sk-ant-api token should set is_oauth=True"
def test_jwt_missing_exp_passes_through(self, tmp_path, monkeypatch):
"""JWT with valid JSON but no exp claim should pass through."""
import base64
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload_data = json.dumps({"sub": "user123"}).encode() # no exp
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
no_exp_jwt = f"{header}.{payload}.fakesig"
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": no_exp_jwt, "refresh_token": "***"},
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
result = _read_codex_access_token()
assert result == no_exp_jwt, "JWT without exp should pass through"
def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch):
"""JWT with valid base64 but invalid JSON payload should pass through."""
import base64
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(b"not-json-content").rstrip(b"=").decode()
bad_jwt = f"{header}.{payload}.fakesig"
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": bad_jwt, "refresh_token": "***"},
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
result = _read_codex_access_token()
assert result == bad_jwt, "JWT with invalid JSON payload should pass through"
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant...oken")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
client, model = _try_anthropic()
assert client is not None
adapter = client.chat.completions
assert adapter._is_oauth is True
class TestVisionClientFallback:
"""Vision client auto mode resolves known-good multimodal backends."""
def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch):
"""Active provider appears in available backends when credentials exist."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
with (
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
):
backends = get_available_vision_backends()
assert "anthropic" in backends
def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
with (
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
):
client, model = resolve_provider_client("anthropic")
assert client is not None
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
assert model == "claude-haiku-4-5-20251001"
class _AuxAuth401(Exception):
status_code = 401
def __init__(self, message="Provided authentication token is expired"):
super().__init__(message)
class _DummyResponse:
def __init__(self, text="ok"):
self.choices = [MagicMock(message=MagicMock(content=text))]
class _FailingThenSuccessCompletions:
def __init__(self):
self.calls = 0
def create(self, **kwargs):
self.calls += 1
if self.calls == 1:
raise _AuxAuth401()
return _DummyResponse("sync-ok")
class _AsyncFailingThenSuccessCompletions:
def __init__(self):
self.calls = 0
async def create(self, **kwargs):
self.calls += 1
if self.calls == 1:
raise _AuxAuth401()
return _DummyResponse("async-ok")
class TestAuxiliaryAuthRefreshRetry:
def test_call_llm_refreshes_codex_on_401_for_vision(self):
failing_client = MagicMock()
failing_client.base_url = "https://chatgpt.com/backend-api/codex"
failing_client.chat.completions = _FailingThenSuccessCompletions()
fresh_client = MagicMock()
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-sync")
with (
patch(
"agent.auxiliary_client.resolve_vision_provider_client",
side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")],
),
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
):
resp = call_llm(
task="vision",
provider="openai-codex",
model="gpt-5.4",
messages=[{"role": "user", "content": "hi"}],
)
assert resp.choices[0].message.content == "fresh-sync"
mock_refresh.assert_called_once_with("openai-codex")
def test_call_llm_refreshes_codex_on_401_for_non_vision(self):
stale_client = MagicMock()
stale_client.base_url = "https://chatgpt.com/backend-api/codex"
stale_client.chat.completions.create.side_effect = _AuxAuth401("stale codex token")
fresh_client = MagicMock()
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-non-vision")
with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("openai-codex", "gpt-5.4", None, None, None)),
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.4"), (fresh_client, "gpt-5.4")]),
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
):
resp = call_llm(
task="compression",
provider="openai-codex",
model="gpt-5.4",
messages=[{"role": "user", "content": "hi"}],
)
assert resp.choices[0].message.content == "fresh-non-vision"
mock_refresh.assert_called_once_with("openai-codex")
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
stale_client = MagicMock()
stale_client.base_url = "https://api.anthropic.com"
stale_client.chat.completions.create.side_effect = _AuxAuth401("anthropic token expired")
fresh_client = MagicMock()
fresh_client.base_url = "https://api.anthropic.com"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-anthropic")
with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)),
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]),
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
):
resp = call_llm(
task="compression",
provider="anthropic",
model="claude-haiku-4-5-20251001",
messages=[{"role": "user", "content": "hi"}],
)
assert resp.choices[0].message.content == "fresh-anthropic"
mock_refresh.assert_called_once_with("anthropic")
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
@pytest.mark.asyncio
async def test_async_call_llm_refreshes_codex_on_401_for_vision(self):
failing_client = MagicMock()
failing_client.base_url = "https://chatgpt.com/backend-api/codex"
failing_client.chat.completions = _AsyncFailingThenSuccessCompletions()
fresh_client = MagicMock()
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async"))
with (
patch(
"agent.auxiliary_client.resolve_vision_provider_client",
side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")],
),
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
):
resp = await async_call_llm(
task="vision",
provider="openai-codex",
model="gpt-5.4",
messages=[{"role": "user", "content": "hi"}],
)
assert resp.choices[0].message.content == "fresh-async"
mock_refresh.assert_called_once_with("openai-codex")
def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts_cache(self, monkeypatch):
stale_client = MagicMock()
cache_key = ("anthropic", False, None, None, None)
monkeypatch.setenv("ANTHROPIC_TOKEN", "")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "")
monkeypatch.setenv("ANTHROPIC_API_KEY", "")
with (
patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "claude-haiku-4-5-20251001", None)}),
patch("hermes_agent_anthropic.adapter.read_claude_code_credentials", return_value={
"accessToken": "expired-token",
"refreshToken": "refresh-token",
"expiresAt": 0,
}),
patch("hermes_agent_anthropic.adapter.refresh_anthropic_oauth_pure", return_value={
"access_token": "***",
"refresh_token": "***",
"expires_at_ms": 9999999999999,
}) as mock_refresh_oauth,
patch("hermes_agent_anthropic.adapter._write_claude_code_credentials") as mock_write,
):
from agent.auxiliary_client import _refresh_provider_credentials
assert _refresh_provider_credentials("anthropic") is True
mock_refresh_oauth.assert_called_once_with("refresh-token", use_json=False)
mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999)
stale_client.close.assert_called_once()
@pytest.mark.asyncio
async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
stale_client = MagicMock()
stale_client.base_url = "https://api.anthropic.com"
stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("anthropic token expired"))
fresh_client = MagicMock()
fresh_client.base_url = "https://api.anthropic.com"
fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-anthropic"))
with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)),
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]),
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
):
resp = await async_call_llm(
task="compression",
provider="anthropic",
model="claude-haiku-4-5-20251001",
messages=[{"role": "user", "content": "hi"}],
)
assert resp.choices[0].message.content == "fresh-async-anthropic"
mock_refresh.assert_called_once_with("anthropic")
assert stale_client.chat.completions.create.await_count == 1
assert fresh_client.chat.completions.create.await_count == 1

View file

@ -0,0 +1,129 @@
"""Anthropic-specific computer use tests moved from tests/tools/test_computer_use.py."""
from __future__ import annotations
from typing import Any, Dict, List
# ---------------------------------------------------------------------------
# Anthropic adapter: multimodal tool-result conversion
# ---------------------------------------------------------------------------
class TestAnthropicAdapterMultimodal:
def test_multimodal_envelope_becomes_tool_result_with_image_block(self):
from hermes_agent_anthropic import convert_messages_to_anthropic
fake_png = "iVBORw0KGgo="
messages = [
{"role": "user", "content": "take a screenshot"},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "computer_use", "arguments": "{}"},
}],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": {
"_multimodal": True,
"content": [
{"type": "text", "text": "1 element"},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{fake_png}"}},
],
"text_summary": "1 element",
},
},
]
_, anthropic_msgs = convert_messages_to_anthropic(messages)
tool_result_msgs = [m for m in anthropic_msgs if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])]
assert tool_result_msgs, "expected a tool_result user message"
tr = next(b for b in tool_result_msgs[-1]["content"] if b.get("type") == "tool_result")
inner = tr["content"]
assert any(b.get("type") == "image" for b in inner)
assert any(b.get("type") == "text" for b in inner)
def test_old_screenshots_are_evicted_beyond_max_keep(self):
"""Image blocks in old tool_results get replaced with placeholders."""
from hermes_agent_anthropic import convert_messages_to_anthropic
fake_png = "iVBORw0KGgo="
def _mm_tool(call_id: str) -> Dict[str, Any]:
return {
"role": "tool",
"tool_call_id": call_id,
"content": {
"_multimodal": True,
"content": [
{"type": "text", "text": "cap"},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{fake_png}"}},
],
"text_summary": "cap",
},
}
# Build 5 screenshots interleaved with assistant messages.
messages: List[Dict[str, Any]] = [{"role": "user", "content": "start"}]
for i in range(5):
messages.append({
"role": "assistant", "content": "",
"tool_calls": [{
"id": f"call_{i}",
"type": "function",
"function": {"name": "computer_use", "arguments": "{}"},
}],
})
messages.append(_mm_tool(f"call_{i}"))
messages.append({"role": "assistant", "content": "done"})
_, anthropic_msgs = convert_messages_to_anthropic(messages)
# Walk tool_result blocks in order; the OLDEST (5 - 3) = 2 should be
# text-only placeholders, newest 3 should still carry image blocks.
tool_results = []
for m in anthropic_msgs:
if m["role"] != "user" or not isinstance(m["content"], list):
continue
for b in m["content"]:
if b.get("type") == "tool_result":
tool_results.append(b)
assert len(tool_results) == 5
with_images = [
b for b in tool_results
if isinstance(b.get("content"), list)
and any(x.get("type") == "image" for x in b["content"])
]
placeholders = [
b for b in tool_results
if isinstance(b.get("content"), list)
and any(
x.get("type") == "text"
and "screenshot removed" in x.get("text", "")
for x in b["content"]
)
]
assert len(with_images) == 3
assert len(placeholders) == 2
def test_content_parts_helper_filters_to_text_and_image(self):
from hermes_agent_anthropic import _content_parts_to_anthropic_blocks
fake_png = "iVBORw0KGgo="
blocks = _content_parts_to_anthropic_blocks([
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{fake_png}"}},
{"type": "unsupported", "data": "ignored"},
])
types = [b["type"] for b in blocks]
assert "text" in types
assert "image" in types
assert len(blocks) == 2

View file

@ -0,0 +1,47 @@
"""Anthropic-specific ctx halving tests moved from tests/test_ctx_halving_fix.py."""
# ---------------------------------------------------------------------------
# build_anthropic_kwargs — output cap clamping
# ---------------------------------------------------------------------------
class TestBuildAnthropicKwargsClamping:
"""The context_length clamp only fires when output ceiling > window.
For standard Anthropic models (output ceiling < window) it must not fire.
"""
def _build(self, model, max_tokens=None, context_length=None):
from hermes_agent_anthropic import build_anthropic_kwargs
return build_anthropic_kwargs(
model=model,
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=max_tokens,
reasoning_config=None,
context_length=context_length,
)
def test_no_clamping_when_output_ceiling_fits_in_window(self):
"""Opus 4.6 native output (128K) < context window (200K) — no clamping."""
kwargs = self._build("claude-opus-4-6", context_length=200_000)
assert kwargs["max_tokens"] == 128_000
def test_clamping_fires_for_tiny_custom_window(self):
"""When context_length is 8K (local model), output cap is clamped to 7999."""
kwargs = self._build("claude-opus-4-6", context_length=8_000)
assert kwargs["max_tokens"] == 7_999
def test_explicit_max_tokens_respected_when_within_window(self):
"""Explicit max_tokens smaller than window passes through unchanged."""
kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000)
assert kwargs["max_tokens"] == 4096
def test_explicit_max_tokens_clamped_when_exceeds_window(self):
"""Explicit max_tokens larger than a small window is clamped."""
kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000)
assert kwargs["max_tokens"] == 15_999
def test_no_context_length_uses_native_ceiling(self):
"""Without context_length the native output ceiling is used directly."""
kwargs = self._build("claude-sonnet-4-6")
assert kwargs["max_tokens"] == 64_000

View file

@ -23,19 +23,29 @@ def _clean_env(monkeypatch):
monkeypatch.delenv(key, raising=False)
def _install_anthropic_adapter_mocks():
"""Patch build_anthropic_client so the test doesn't need the SDK."""
def _make_fake_anthropic_namespace(build_side_effect=None, build_return=None):
"""Return a fake provider namespace dict and fake client for registry patching.
_try_custom_endpoint() resolves build_anthropic_client through
registries.get_provider_namespace("anthropic"), not via a direct module
import, so we must patch that path (not hermes_agent_anthropic.adapter).
"""
fake_client = MagicMock(name="anthropic_client")
return patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=fake_client,
), fake_client
if build_side_effect is not None:
mock_build = MagicMock(side_effect=build_side_effect)
else:
mock_build = MagicMock(return_value=build_return or fake_client)
fake_ns = {"build_anthropic_client": mock_build}
return fake_ns, fake_client, mock_build
def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
"""api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI."""
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
fake_ns, fake_client, _ = _make_fake_anthropic_namespace()
with patch(
"agent.auxiliary_client._resolve_custom_runtime",
return_value=(
@ -46,10 +56,11 @@ def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
), patch(
"agent.auxiliary_client._read_main_model",
return_value="claude-sonnet-4-6",
):
adapter_patch, fake_client = _install_anthropic_adapter_mocks()
with adapter_patch:
client, model = _try_custom_endpoint()
), patch(
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
client, model = _try_custom_endpoint()
assert isinstance(client, AnthropicAuxiliaryClient), (
"Custom endpoint with api_mode=anthropic_messages must return the "
@ -67,6 +78,7 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
from agent.auxiliary_client import _try_custom_endpoint
import_error = ImportError("anthropic package not installed")
fake_ns, _, _ = _make_fake_anthropic_namespace(build_side_effect=import_error)
with patch(
"agent.auxiliary_client._resolve_custom_runtime",
@ -75,9 +87,9 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
"agent.auxiliary_client._read_main_model",
return_value="claude-sonnet-4-6",
), patch(
"agent.anthropic_adapter.build_anthropic_client",
side_effect=import_error,
):
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
client, model = _try_custom_endpoint()
# Should fall back to an OpenAI-wire client rather than returning

View file

@ -0,0 +1,231 @@
"""Anthropic-specific fast mode tests moved from tests/cli/test_fast_command.py."""
import unittest
from types import SimpleNamespace
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class TestAnthropicFastMode(unittest.TestCase):
"""Verify Anthropic Fast Mode model support and override resolution."""
def test_anthropic_opus_supported(self):
from hermes_cli.models import model_supports_fast_mode
# Native Anthropic format (hyphens)
assert model_supports_fast_mode("claude-opus-4-6") is True
# OpenRouter format (dots)
assert model_supports_fast_mode("claude-opus-4.6") is True
# With vendor prefix
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_non_opus46_models_excluded(self):
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
"""
from hermes_cli.models import model_supports_fast_mode
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_non_claude_models_not_anthropic_fast(self):
"""Non-Claude models should not be treated as Anthropic fast-mode."""
from hermes_cli.models import _is_anthropic_fast_model
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("gemini-3-pro") is False
assert _is_anthropic_fast_model("kimi-k2-thinking") is False
def test_anthropic_variant_tags_stripped(self):
from hermes_cli.models import model_supports_fast_mode
# OpenRouter variant tags after colon should be stripped
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
def test_resolve_overrides_returns_speed_for_anthropic(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("claude-opus-4-6")
assert result == {"speed": "fast"}
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
Per Anthropic docs, fast mode is currently Opus 4.6 only.
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
def test_resolve_overrides_returns_service_tier_for_openai(self):
"""OpenAI models should still get service_tier, not speed."""
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("gpt-5.4")
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
assert _is_anthropic_fast_model("claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6") is True
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
# Non-Claude
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("") is False
def test_fast_command_exposed_for_anthropic_model(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-7", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_non_claude_non_openai(self):
"""Non-Claude, non-OpenAI models should not expose /fast."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="gemini", requested_provider="gemini",
model="gemini-3-pro-preview", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_turn_route_injects_speed_for_anthropic(self):
"""Anthropic models should get speed:'fast' override, not service_tier."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="claude-opus-4-6",
api_key="sk-ant-test",
base_url="https://api.anthropic.com",
provider="anthropic",
api_mode="anthropic_messages",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "anthropic"
assert route["request_overrides"] == {"speed": "fast"}
class TestAnthropicFastModeAdapter(unittest.TestCase):
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
def test_fast_mode_adds_speed_and_beta(self):
from hermes_agent_anthropic import build_anthropic_kwargs, _FAST_MODE_BETA
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
)
assert kwargs.get("extra_body", {}).get("speed") == "fast"
assert "speed" not in kwargs
assert "extra_headers" in kwargs
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
def test_fast_mode_off_no_speed(self):
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=False,
)
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
def test_fast_mode_skipped_for_third_party_endpoint(self):
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
base_url="https://api.minimax.io/anthropic/v1",
)
# Third-party endpoints should NOT get speed or fast-mode beta
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self):
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
)
assert "speed" not in kwargs
assert kwargs.get("extra_body", {}).get("speed") == "fast"

View file

@ -6,7 +6,7 @@ from unittest.mock import patch, MagicMock
import pytest
from agent.anthropic_adapter import (
from hermes_agent_anthropic.adapter import (
_read_claude_code_credentials_from_keychain,
read_claude_code_credentials,
)
@ -17,42 +17,42 @@ class TestReadClaudeCodeCredentialsFromKeychain:
def test_returns_none_on_linux(self):
"""Keychain reading is Darwin-only; must return None on other platforms."""
with patch("agent.anthropic_adapter.platform.system", return_value="Linux"):
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Linux"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_windows(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Windows"):
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Windows"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_security_command_not_found(self):
"""OSError from missing security binary must be handled gracefully."""
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run",
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run",
side_effect=OSError("security not found")):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_nonzero_exit_code(self):
"""security returns non-zero when the Keychain entry doesn't exist."""
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_empty_stdout(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_non_json_payload(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}),
@ -61,8 +61,8 @@ class TestReadClaudeCodeCredentialsFromKeychain:
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_access_token_is_empty(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}),
@ -71,8 +71,8 @@ class TestReadClaudeCodeCredentialsFromKeychain:
assert _read_claude_code_credentials_from_keychain() is None
def test_parses_valid_keychain_entry(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
@ -107,11 +107,11 @@ class TestReadClaudeCodeCredentialsPriority:
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
# Mock Keychain to return a "newer" token
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
@ -141,10 +141,10 @@ class TestReadClaudeCodeCredentialsPriority:
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
# Simulate Keychain entry not found
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()
@ -155,10 +155,10 @@ class TestReadClaudeCodeCredentialsPriority:
def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch):
"""No credentials anywhere — must return None cleanly."""
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()

View file

@ -191,7 +191,7 @@ class TestAnthropicOAuthOutgoingPrefix:
tools registered as ``mcp_<server>_<tool>``). GH-25255."""
def _build(self, tools, is_oauth=True):
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Hi"}],

View file

@ -30,7 +30,7 @@ class TestStaleOAuthTokenDetection:
# No valid Claude Code credentials available (expired, no refresh token)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: {
"accessToken": "expired-cc-token",
"refreshToken": "", # No refresh — can't recover
@ -39,16 +39,16 @@ class TestStaleOAuthTokenDetection:
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: False, # Explicitly expired
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
# _resolve_claude_code_token_from_credentials has no valid path
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
"hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: None,
)
@ -80,15 +80,15 @@ class TestStaleOAuthTokenDetection:
save_env_value("ANTHROPIC_TOKEN", "")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: None, # No CC creds
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-") and "oat" in key,
)
@ -116,7 +116,7 @@ class TestStaleOAuthTokenDetection:
# Valid Claude Code credentials with refresh token
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: {
"accessToken": "valid-cc-token",
"refreshToken": "valid-refresh",
@ -124,15 +124,15 @@ class TestStaleOAuthTokenDetection:
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: True,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
"hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: "valid-cc-token",
)

View file

@ -114,7 +114,7 @@ def test_authorization_url_state_is_not_pkce_verifier(monkeypatch, tmp_path):
monkeypatch.setattr(builtins, "input", fake_input)
from agent.anthropic_adapter import run_hermes_oauth_login_pure
from hermes_agent_anthropic import run_hermes_oauth_login_pure
result = run_hermes_oauth_login_pure()
assert result is not None, "OAuth flow should succeed with matching state"
@ -160,7 +160,7 @@ def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog):
capture_token_request=captured_token,
)
from agent.anthropic_adapter import run_hermes_oauth_login_pure
from hermes_agent_anthropic import run_hermes_oauth_login_pure
result = run_hermes_oauth_login_pure()

View file

@ -64,9 +64,9 @@ class TestOAuthFlagOnRefresh:
agent._is_anthropic_oauth = False
with (
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
):
result = agent._try_refresh_anthropic_client_credentials()
@ -85,9 +85,9 @@ class TestOAuthFlagOnRefresh:
agent._is_anthropic_oauth = False
with (
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
):
result = agent._try_refresh_anthropic_client_credentials()
@ -111,7 +111,7 @@ class TestOAuthFlagOnCredentialSwap:
entry.runtime_api_key = _OAUTH_LIKE_TOKEN
entry.runtime_base_url = "https://open.bigmodel.cn/api/anthropic"
with patch("agent.anthropic_adapter.build_anthropic_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()):
agent._swap_credential(entry)
@ -125,11 +125,11 @@ class TestOAuthFlagOnConstruction:
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
# Simulate a stale ANTHROPIC_TOKEN in the env — the init code
# MUST NOT fall back to it when provider != anthropic.
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
):
agent = AIAgent(
@ -154,7 +154,7 @@ class TestOAuthFlagOnFallbackActivation:
def test_fallback_to_third_party_does_not_flip_oauth(self, agent):
"""Directly mimic the post-fallback assignment at line ~6537."""
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
# Emulate the relevant lines of _try_activate_fallback without
# running the entire recovery stack (which pulls in streaming,
@ -171,11 +171,11 @@ class TestApiKeyTokensAlwaysSafe:
"""Regression: plain API-key shapes must always resolve to non-OAuth, any provider."""
def test_native_anthropic_with_api_key_token(self):
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
assert _is_oauth_token(_API_KEY_TOKEN) is False
def test_third_party_key_shape(self):
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
# Third-party key shapes (MiniMax 'mxp-...', GLM 'glm.sess.', etc.)
# already return False from _is_oauth_token; the guard adds a second
# defense line in case future token formats accidentally look OAuth-y.

View file

@ -0,0 +1,22 @@
"""Anthropic-specific timeout tests moved from tests/hermes_cli/test_timeouts.py."""
from __future__ import annotations
def test_anthropic_adapter_honors_timeout_kwarg():
"""build_anthropic_client(timeout=X) overrides the 900s default read timeout."""
pytest = __import__("pytest")
anthropic = pytest.importorskip("anthropic") # skip if optional SDK missing
from hermes_agent_anthropic import build_anthropic_client
c_default = build_anthropic_client("sk-ant-dummy", None)
c_custom = build_anthropic_client("sk-ant-dummy", None, timeout=45.0)
c_invalid = build_anthropic_client("sk-ant-dummy", None, timeout=-1)
# Default stays at 900s; custom overrides; invalid falls back to default
assert c_default.timeout.read == 900.0
assert c_custom.timeout.read == 45.0
assert c_invalid.timeout.read == 900.0
# Connect timeout always stays at 10s regardless
assert c_default.timeout.connect == 10.0
assert c_custom.timeout.connect == 10.0

View file

@ -38,7 +38,7 @@ class TestDeepSeekAnthropicPreservesThinking:
)
def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
"""Unsigned thinking (synthesised from reasoning_content) must be preserved."""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -75,7 +75,7 @@ class TestDeepSeekAnthropicPreservesThinking:
def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:
"""DeepSeek validates history across every prior assistant turn, not just last."""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "q1"},
@ -125,7 +125,7 @@ class TestDeepSeekAnthropicPreservesThinking:
DeepSeek issues its own signatures and cannot validate Anthropic's —
the strip-signed / keep-unsigned split matches the Kimi policy.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -163,7 +163,7 @@ class TestDeepSeekAnthropicPreservesThinking:
as ignored cache markers interfere with signature validation on
upstreams that do check them, so Hermes strips them everywhere.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -200,7 +200,7 @@ class TestDeepSeekAnthropicPreservesThinking:
detector should still fail closed so an accidental misuse doesn't
quietly send signed Anthropic blocks to an OpenAI endpoint.
"""
from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint
from hermes_agent_anthropic import _is_deepseek_anthropic_endpoint
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False
@ -211,7 +211,7 @@ class TestDeepSeekAnthropicPreservesThinking:
"""MiniMax and other third-party Anthropic endpoints must keep the
generic strip-all behaviour (they reject unsigned blocks outright).
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},

View file

@ -37,7 +37,7 @@ class TestKimiCodingSkipsAnthropicThinking:
],
)
def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -54,7 +54,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert "output_config" not in kwargs
def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -68,7 +68,7 @@ class TestKimiCodingSkipsAnthropicThinking:
def test_non_kimi_third_party_still_gets_thinking(self) -> None:
"""MiniMax and other third-party Anthropic endpoints must retain thinking."""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@ -82,7 +82,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert kwargs["thinking"]["type"] == "enabled"
def test_native_anthropic_still_gets_thinking(self) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
@ -105,7 +105,7 @@ class TestKimiCodingSkipsAnthropicThinking:
suppression must apply to every Kimi host, not just ``/coding``.
See #17057.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -136,7 +136,7 @@ class TestKimiCodingSkipsAnthropicThinking:
self, base_url: str, model: str
) -> None:
"""Custom / proxied Kimi endpoints must also strip Anthropic thinking."""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model=model,
@ -159,7 +159,7 @@ class TestKimiCodingSkipsAnthropicThinking:
Guards against over-broad model-family matching only model names
starting with a Kimi/Moonshot prefix should trigger suppression.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@ -177,7 +177,7 @@ class TestKimiCodingSkipsAnthropicThinking:
blocks must survive the third-party signature-stripping pass so
the upstream's message-history validation passes.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from hermes_agent_anthropic import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},

View file

@ -32,7 +32,7 @@ class TestMinimaxThinkingSupport:
"""
def test_minimax_m27_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
messages=[{"role": "user", "content": "hello"}],
@ -47,7 +47,7 @@ class TestMinimaxThinkingSupport:
assert "output_config" not in kwargs
def test_minimax_m25_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.5",
messages=[{"role": "user", "content": "hello"}],
@ -59,7 +59,7 @@ class TestMinimaxThinkingSupport:
assert kwargs["thinking"]["type"] == "enabled"
def test_thinking_still_works_for_claude(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hello"}],
@ -99,8 +99,8 @@ class TestMinimaxBetaHeaders:
def _build_and_get_betas(self, api_key, base_url=None):
"""Build client, return the anthropic-beta header string."""
from agent.anthropic_adapter import build_anthropic_client
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
from hermes_agent_anthropic import build_anthropic_client
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(api_key, base_url=base_url)
kwargs = mock_sdk.Anthropic.call_args[1]
headers = kwargs.get("default_headers", {})
@ -158,26 +158,26 @@ class TestMinimaxBetaHeaders:
# -- _common_betas_for_base_url unit tests ---------------------------
def test_common_betas_none_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url(None) == _COMMON_BETAS
def test_common_betas_empty_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("") == _COMMON_BETAS
def test_common_betas_minimax_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
assert _TOOL_STREAMING_BETA not in betas
assert len(betas) > 0 # still has other betas
def test_common_betas_minimax_cn_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
assert _TOOL_STREAMING_BETA not in betas
def test_common_betas_regular_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
@ -222,19 +222,19 @@ class TestMinimaxMaxOutput:
"""
def test_minimax_m27_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
def test_minimax_m25_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
def test_minimax_m2_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
def test_claude_output_unaffected(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from hermes_agent_anthropic import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
@ -301,21 +301,21 @@ class TestMinimaxPreserveDots:
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_normalize_preserves_m25_free_dot(self):
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free"
def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
def test_normalize_preserves_non_anthropic_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
# Non-Anthropic model families use dots as canonical version separators;
# only Claude/Anthropic names are hyphen-normalized by default.
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7"
def test_normalize_still_converts_claude_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6"
@ -348,9 +348,9 @@ class TestMinimaxSwitchModelCredentialGuard:
agent._anthropic_client = MagicMock()
agent._fallback_chain = []
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("hermes_agent_anthropic.adapter._is_oauth_token", return_value=False):
agent.switch_model(
new_model="MiniMax-M2.7",

View file

@ -11,3 +11,9 @@ arcee = ProviderProfile(
)
register_provider(arcee)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -19,3 +19,9 @@ azure_foundry = ProviderProfile(
)
register_provider(azure_foundry)
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_azure package."""
from hermes_agent_azure import register as _inner_register
_inner_register(ctx)

View file

@ -0,0 +1,42 @@
"""hermes-agent-azure: Microsoft Entra ID / Azure Identity adapter for Hermes Agent."""
from hermes_agent_azure.adapter import ( # noqa: F401
SCOPE_AI_AZURE_DEFAULT,
EntraIdentityConfig,
_build_default_credential,
_require_azure_identity,
build_bearer_http_client,
build_credential,
build_token_provider,
describe_active_credential,
has_azure_identity_credentials,
has_azure_identity_installed,
is_token_provider,
materialize_bearer_for_http,
reset_credential_cache,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group."""
from hermes_agent_azure import adapter
ctx.register_provider_services("azure", {
# Auth / credentials
"is_token_provider": adapter.is_token_provider,
"has_azure_identity_credentials": adapter.has_azure_identity_credentials,
"has_azure_identity_installed": adapter.has_azure_identity_installed,
# Client building
"build_bearer_http_client": adapter.build_bearer_http_client,
"build_credential": adapter.build_credential,
"build_token_provider": adapter.build_token_provider,
"materialize_bearer_for_http": adapter.materialize_bearer_for_http,
"reset_credential_cache": adapter.reset_credential_cache,
# Constants / config
"SCOPE_AI_AZURE_DEFAULT": adapter.SCOPE_AI_AZURE_DEFAULT,
"EntraIdentityConfig": adapter.EntraIdentityConfig,
# Internal helpers
"_build_default_credential": adapter._build_default_credential,
"_require_azure_identity": adapter._require_azure_identity,
"describe_active_credential": adapter.describe_active_credential,
})

View file

@ -54,8 +54,6 @@ SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default"
# Lazy SDK import — only loaded when the Entra path is actually used.
# ---------------------------------------------------------------------------
_AZURE_IDENTITY_FEATURE = "provider.azure_identity"
def has_azure_identity_installed() -> bool:
"""Return True if `azure-identity` can be imported right now.
@ -70,35 +68,20 @@ def has_azure_identity_installed() -> bool:
def _require_azure_identity():
"""Import ``azure.identity``, lazy-installing it if allowed.
"""Import ``azure.identity``.
Raises ``ImportError`` with a clear actionable message when the
package is missing and lazy installs are disabled.
package is missing.
"""
try:
import azure.identity as _ai
return _ai
except ImportError:
try:
from tools.lazy_deps import ensure, FeatureUnavailable
except ImportError as exc:
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. Install it with: "
"pip install azure-identity"
) from exc
try:
ensure(_AZURE_IDENTITY_FEATURE, prompt=False)
except FeatureUnavailable as exc:
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. " + str(exc)
) from exc
# Retry import after lazy install.
import azure.identity as _ai # noqa: WPS440
return _ai
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. Install it with: "
"pip install azure-identity"
)
def reset_credential_cache() -> None:

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-azure"
version = "0.1.0"
description = "Microsoft Entra ID / Azure Identity adapter for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"azure-identity==1.25.3",
]
[project.entry-points."hermes_agent.plugins"]
azure = "hermes_agent_azure:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_azure*"]

View file

@ -34,7 +34,7 @@ import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@ -242,7 +242,7 @@ class TestAuxAzureFoundryEntra:
event hook on a custom ``httpx.Client`` passed to the
Anthropic SDK via ``http_client=``."""
from agent import auxiliary_client as _aux
from agent import anthropic_adapter as _anthropic
from hermes_agent_anthropic import adapter as _anthropic
received = {}

View file

@ -31,7 +31,7 @@ import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@ -151,7 +151,7 @@ class TestResolveAzureFoundryRuntimeEntra:
``cognitiveservices.azure.com`` scope is the control-plane
audience and is rejected for inference by newer resources."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={

View file

@ -32,7 +32,7 @@ import pytest
# about cache invalidation.
@pytest.fixture(autouse=True)
def _reset_adapter_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@ -61,7 +61,7 @@ class TestEntraScopeConstant:
"""
def test_default_scope_matches_microsoft_documentation(self):
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT
assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default"
@ -75,7 +75,7 @@ class TestMaterializeBearerForHttp:
callable exactly once and never fall through to display masking."""
def test_callable_is_invoked_and_returns_token(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
invoked = {"count": 0}
@ -87,16 +87,16 @@ class TestMaterializeBearerForHttp:
assert invoked["count"] == 1
def test_string_passes_through(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
assert materialize_bearer_for_http("plain-key") == "plain-key"
def test_callable_returning_empty_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http(lambda: "")
def test_empty_string_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http("")
with pytest.raises(ValueError):
@ -116,7 +116,7 @@ class TestBuildBearerHttpClient:
def test_returns_httpx_client_with_request_hook(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
client = build_bearer_http_client(lambda: "jwt")
try:
@ -128,7 +128,7 @@ class TestBuildBearerHttpClient:
def test_hook_overrides_authorization_header(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
minted_tokens = []
@ -180,7 +180,7 @@ class TestBuildBearerHttpClient:
"""
import logging
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
def bad_provider():
return "" # empty token → materialize_bearer_for_http raises
@ -209,7 +209,7 @@ class TestBuildBearerHttpClient:
client.close()
def test_rejects_non_callable_provider(self):
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable"))
with pytest.raises(ValueError):
@ -217,7 +217,7 @@ class TestBuildBearerHttpClient:
def test_forwards_httpx_kwargs(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
timeout = httpx.Timeout(60.0, connect=5.0)
client = build_bearer_http_client(lambda: "jwt", timeout=timeout)
@ -231,11 +231,11 @@ class TestBuildBearerHttpClient:
class TestIsTokenProvider:
def test_callable_is_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
from hermes_agent_azure import is_token_provider
assert is_token_provider(lambda: "x") is True
def test_string_is_not_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
from hermes_agent_azure import is_token_provider
assert is_token_provider("static-key") is False
# ``str`` instances are technically callable in some edge cases
# — confirm they're never classified as token providers.
@ -252,7 +252,7 @@ class TestEntraIdentityConfig:
must round-trip through dict cleanly and never lose fields."""
def test_to_dict_round_trip(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig(
scope="https://ai.azure.com/.default",
exclude_interactive_browser=False,
@ -261,7 +261,7 @@ class TestEntraIdentityConfig:
assert rebuilt == cfg
def test_from_dict_handles_empty_strings(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"scope": "",
"client_id": None,
@ -273,7 +273,7 @@ class TestEntraIdentityConfig:
"""Old config.yaml that still has model.entra.client_id /
tenant_id / authority should not crash from_dict those values
are now read from AZURE_* env vars by azure-identity directly."""
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"tenant_id": "legacy-tenant",
"authority": "https://login.partner.microsoftonline.cn",
@ -285,12 +285,12 @@ class TestEntraIdentityConfig:
assert not hasattr(cfg, "authority")
def test_constructor_normalizes_empty_scope(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig(scope="")
assert cfg.scope.endswith("/.default")
def test_from_dict_default_scope_override(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict(
{"scope": ""},
default_scope="https://custom.example/.default",
@ -299,7 +299,7 @@ class TestEntraIdentityConfig:
def test_dataclass_is_frozen(self):
# Frozen dataclasses are hashable / safe to pass through caches.
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig()
with pytest.raises((AttributeError, Exception)):
setattr(cfg, "scope", "mutated")
@ -365,7 +365,7 @@ class TestBuildCredential:
browser auth. Tenant / authority / service principal config
flow through the standard ``AZURE_*`` env vars (read by
azure-identity directly), not Hermes config kwargs."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
cred = build_credential(EntraIdentityConfig())
kwargs = fake_azure_identity.last_credential_kwargs
# Default config should produce empty kwargs — SDK uses its own
@ -378,13 +378,13 @@ class TestBuildCredential:
``exclude_interactive_browser=False``, the SDK kwarg is set to
False. Without the opt-in we don't pass the kwarg at all (SDK
default is True / browser excluded)."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
build_credential(EntraIdentityConfig(exclude_interactive_browser=False))
kwargs = fake_azure_identity.last_credential_kwargs
assert kwargs["exclude_interactive_browser_credential"] is False
def test_credential_is_cached_per_config(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
cfg = EntraIdentityConfig(scope="s1")
c1 = build_credential(cfg)
c2 = build_credential(cfg)
@ -392,14 +392,14 @@ class TestBuildCredential:
assert fake_azure_identity.credential_count == 1
def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
c1 = build_credential(EntraIdentityConfig(scope="s1"))
c2 = build_credential(EntraIdentityConfig(scope="s2"))
assert c1 is not c2
assert fake_azure_identity.credential_count == 2
def test_reset_cache_invalidates(self, fake_azure_identity):
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
EntraIdentityConfig,
build_credential,
reset_credential_cache,
@ -413,7 +413,7 @@ class TestBuildCredential:
class TestBuildTokenProvider:
def test_returns_callable_for_scope(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
from hermes_agent_azure import build_token_provider
provider = build_token_provider(scope="https://ai.azure.com/.default")
assert callable(provider)
assert provider() == "jwt-for-https://ai.azure.com/.default"
@ -424,7 +424,7 @@ class TestBuildTokenProvider:
``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT``
Microsoft's documented Foundry inference scope. ``base_url`` is
accepted for back-compat but ignored."""
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
@ -432,7 +432,7 @@ class TestBuildTokenProvider:
assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT
def test_explicit_scope_wins_over_base_url(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
from hermes_agent_azure import build_token_provider
build_token_provider(
scope="https://override.example/.default",
base_url="https://r.openai.azure.com/openai/v1",
@ -440,7 +440,7 @@ class TestBuildTokenProvider:
assert fake_azure_identity.last_scope == "https://override.example/.default"
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
EntraIdentityConfig,
build_token_provider,
)
@ -456,11 +456,10 @@ class TestBuildTokenProvider:
class TestRequireAzureIdentityMissing:
def test_clear_error_when_lazy_install_disabled(self, monkeypatch):
"""When azure-identity isn't importable AND lazy installs are
off, the adapter must raise ImportError with an actionable
message, not propagate FeatureUnavailable."""
from agent import azure_identity_adapter as _adapter
def test_clear_error_when_azure_identity_missing(self, monkeypatch):
"""When azure-identity isn't importable, the adapter must raise
ImportError with an actionable message."""
from hermes_agent_azure import azure_identity_adapter as _adapter
# Force the import path to fail.
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
@ -471,20 +470,6 @@ class TestRequireAzureIdentityMissing:
monkeypatch.setattr("builtins.__import__", _fake_import)
# Simulate lazy installs disabled.
from tools.lazy_deps import FeatureUnavailable
def _fake_ensure(*args, **kwargs):
raise FeatureUnavailable(
"provider.azure_identity",
("azure-identity==1.25.3",),
"lazy installs disabled (test simulation)",
)
# The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept
# it by patching the actual symbol path.
monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure)
with pytest.raises(ImportError) as exc_info:
_adapter._require_azure_identity()
msg = str(exc_info.value)
@ -547,7 +532,7 @@ class TestHasAzureIdentityCredentials:
assert result is True
def test_returns_true_on_successful_token_mint(self, fake_azure_identity):
from agent.azure_identity_adapter import has_azure_identity_credentials
from hermes_agent_azure import has_azure_identity_credentials
assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True
def test_returns_false_when_get_token_raises(self, monkeypatch):
@ -623,7 +608,7 @@ class TestDescribeActiveCredential:
assert "lazy" in info["hint"].lower()
def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is True
@ -631,14 +616,14 @@ class TestDescribeActiveCredential:
assert any("ManagedIdentity" in s for s in sources)
def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("WorkloadIdentity" in s for s in sources)
def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("AZURE_TENANT_ID", "t")
monkeypatch.setenv("AZURE_CLIENT_ID", "c")
monkeypatch.setenv("AZURE_CLIENT_SECRET", "s")

View file

@ -27,3 +27,9 @@ bedrock = BedrockProfile(
)
register_provider(bedrock)
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_bedrock package."""
from hermes_agent_bedrock import register as _inner_register
_inner_register(ctx)

View file

@ -0,0 +1,94 @@
"""hermes-agent-bedrock: AWS Bedrock Converse API adapter for Hermes Agent."""
from hermes_agent_bedrock.adapter import ( # noqa: F401
BEDROCK_DEFAULT_CONTEXT_LENGTH,
CONTEXT_OVERFLOW_PATTERNS,
OVERLOAD_PATTERNS,
THROTTLE_PATTERNS,
_AWS_CREDENTIAL_ENV_VARS,
_DISCOVERY_CACHE_TTL_SECONDS,
_NON_TOOL_CALLING_PATTERNS,
_STALE_LIB_MODULE_PREFIXES,
_convert_content_to_converse,
_converse_stop_reason_to_openai,
_extract_provider_from_arn,
_get_bedrock_control_client,
_get_bedrock_runtime_client,
_model_supports_tool_use,
_require_boto3,
_traceback_frames_modules,
bedrock_model_ids_or_none,
build_converse_kwargs,
call_converse,
call_converse_stream,
classify_bedrock_error,
convert_messages_to_converse,
convert_tools_to_converse,
discover_bedrock_models,
get_bedrock_context_length,
get_bedrock_model_ids,
has_aws_credentials,
invalidate_runtime_client,
is_anthropic_bedrock_model,
is_context_overflow_error,
is_stale_connection_error,
normalize_converse_response,
normalize_converse_stream_events,
reset_client_cache,
reset_discovery_cache,
resolve_aws_auth_env_var,
resolve_bedrock_region,
stream_converse_with_callbacks,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group."""
from hermes_agent_bedrock import adapter
ctx.register_provider_services("bedrock", {
# Auth / credentials
"has_aws_credentials": adapter.has_aws_credentials,
"resolve_aws_auth_env_var": adapter.resolve_aws_auth_env_var,
"resolve_bedrock_region": adapter.resolve_bedrock_region,
"_AWS_CREDENTIAL_ENV_VARS": adapter._AWS_CREDENTIAL_ENV_VARS,
# Transport
"build_converse_kwargs": adapter.build_converse_kwargs,
"convert_messages_to_converse": adapter.convert_messages_to_converse,
"convert_tools_to_converse": adapter.convert_tools_to_converse,
"normalize_converse_response": adapter.normalize_converse_response,
"normalize_converse_stream_events": adapter.normalize_converse_stream_events,
"call_converse": adapter.call_converse,
"call_converse_stream": adapter.call_converse_stream,
"stream_converse_with_callbacks": adapter.stream_converse_with_callbacks,
# Model metadata
"bedrock_model_ids_or_none": adapter.bedrock_model_ids_or_none,
"discover_bedrock_models": adapter.discover_bedrock_models,
"get_bedrock_context_length": adapter.get_bedrock_context_length,
"get_bedrock_model_ids": adapter.get_bedrock_model_ids,
"BEDROCK_DEFAULT_CONTEXT_LENGTH": adapter.BEDROCK_DEFAULT_CONTEXT_LENGTH,
# Client management
"_get_bedrock_control_client": adapter._get_bedrock_control_client,
"_get_bedrock_runtime_client": adapter._get_bedrock_runtime_client,
"invalidate_runtime_client": adapter.invalidate_runtime_client,
"reset_client_cache": adapter.reset_client_cache,
"reset_discovery_cache": adapter.reset_discovery_cache,
# Error handling
"classify_bedrock_error": adapter.classify_bedrock_error,
"is_context_overflow_error": adapter.is_context_overflow_error,
"is_stale_connection_error": adapter.is_stale_connection_error,
"CONTEXT_OVERFLOW_PATTERNS": adapter.CONTEXT_OVERFLOW_PATTERNS,
"OVERLOAD_PATTERNS": adapter.OVERLOAD_PATTERNS,
"THROTTLE_PATTERNS": adapter.THROTTLE_PATTERNS,
"_NON_TOOL_CALLING_PATTERNS": adapter._NON_TOOL_CALLING_PATTERNS,
"_STALE_LIB_MODULE_PREFIXES": adapter._STALE_LIB_MODULE_PREFIXES,
"_DISCOVERY_CACHE_TTL_SECONDS": adapter._DISCOVERY_CACHE_TTL_SECONDS,
# Internal helpers
"_require_boto3": adapter._require_boto3,
"_model_supports_tool_use": adapter._model_supports_tool_use,
"is_anthropic_bedrock_model": adapter.is_anthropic_bedrock_model,
"_convert_content_to_converse": adapter._convert_content_to_converse,
"_converse_stop_reason_to_openai": adapter._converse_stop_reason_to_openai,
"_extract_provider_from_arn": adapter._extract_provider_from_arn,
"_traceback_frames_modules": adapter._traceback_frames_modules,
})

View file

@ -36,19 +36,6 @@ from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Ensure boto3/botocore are installed before any code in this module runs.
# Upstream removed boto3 from [all] extras (PRs #24220, #24515); lazy_deps
# handles on-demand installation so the Bedrock provider still works in the
# EKS deployment without baking boto3 into the base image.
# ---------------------------------------------------------------------------
try:
from tools.lazy_deps import ensure
ensure("provider.bedrock", prompt=False)
except Exception:
pass # lazy_deps unavailable or install failed — let downstream imports surface the real error
# ---------------------------------------------------------------------------
# Lazy boto3 import — only loaded when the Bedrock provider is actually used.
# This keeps startup fast for users who don't use Bedrock.

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-bedrock"
version = "0.1.0"
description = "AWS Bedrock Converse API adapter for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"boto3==1.42.89",
]
[project.entry-points."hermes_agent.plugins"]
bedrock = "hermes_agent_bedrock:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_bedrock*"]

View file

@ -19,7 +19,7 @@ class TestBedrockContext1MBeta:
def test_common_betas_strips_1m_for_minimax(self):
"""MiniMax bearer-auth endpoints host their own models — strip 1M beta."""
from agent.anthropic_adapter import (
from hermes_agent_anthropic import (
_common_betas_for_base_url,
_CONTEXT_1M_BETA,
)
@ -41,7 +41,7 @@ class TestBedrockContext1MBeta:
This is the load-bearing assertion for the reported bug:
without this header Bedrock serves Opus 4.6/4.7 with a 200K cap.
"""
import agent.anthropic_adapter as adapter
import hermes_agent_anthropic as adapter
fake_sdk = MagicMock()
fake_sdk.AnthropicBedrock = MagicMock()

View file

@ -41,7 +41,7 @@ class TestResolveAwsAuthEnvVar:
"""
def test_prefers_bearer_token_over_access_keys_and_profile(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {
"AWS_BEARER_TOKEN_BEDROCK": "bearer-token",
"AWS_ACCESS_KEY_ID": "AKIA...",
@ -51,7 +51,7 @@ class TestResolveAwsAuthEnvVar:
assert resolve_aws_auth_env_var(env) == "AWS_BEARER_TOKEN_BEDROCK"
def test_uses_access_keys_when_bearer_token_missing(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {
"AWS_ACCESS_KEY_ID": "AKIA...",
"AWS_SECRET_ACCESS_KEY": "secret",
@ -60,28 +60,28 @@ class TestResolveAwsAuthEnvVar:
assert resolve_aws_auth_env_var(env) == "AWS_ACCESS_KEY_ID"
def test_requires_both_access_key_and_secret(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
# Only access key, no secret → should not match
env = {"AWS_ACCESS_KEY_ID": "AKIA..."}
assert resolve_aws_auth_env_var(env) != "AWS_ACCESS_KEY_ID"
def test_uses_profile_when_no_keys(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {"AWS_PROFILE": "production"}
assert resolve_aws_auth_env_var(env) == "AWS_PROFILE"
def test_uses_container_credentials(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/..."}
assert resolve_aws_auth_env_var(env) == "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
def test_uses_web_identity(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token"}
assert resolve_aws_auth_env_var(env) == "AWS_WEB_IDENTITY_TOKEN_FILE"
def test_returns_none_when_no_aws_auth(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
# Mock botocore to return no credentials (covers EC2 IMDS fallback)
mock_session = MagicMock()
mock_session.get_credentials.return_value = None
@ -91,7 +91,7 @@ class TestResolveAwsAuthEnvVar:
assert resolve_aws_auth_env_var({}) is None
def test_ignores_whitespace_only_values(self):
from agent.bedrock_adapter import resolve_aws_auth_env_var
from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var
env = {"AWS_PROFILE": " ", "AWS_ACCESS_KEY_ID": " "}
mock_session = MagicMock()
mock_session.get_credentials.return_value = None
@ -103,11 +103,11 @@ class TestResolveAwsAuthEnvVar:
class TestHasAwsCredentials:
def test_true_with_profile(self):
from agent.bedrock_adapter import has_aws_credentials
from hermes_agent_bedrock.adapter import has_aws_credentials
assert has_aws_credentials({"AWS_PROFILE": "default"}) is True
def test_false_with_empty_env(self):
from agent.bedrock_adapter import has_aws_credentials
from hermes_agent_bedrock.adapter import has_aws_credentials
mock_session = MagicMock()
mock_session.get_credentials.return_value = None
with patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}):
@ -118,17 +118,17 @@ class TestHasAwsCredentials:
class TestResolveBedrocRegion:
def test_prefers_aws_region(self):
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
env = {"AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "us-west-2"}
assert resolve_bedrock_region(env) == "eu-west-1"
def test_falls_back_to_default_region(self):
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
env = {"AWS_DEFAULT_REGION": "ap-northeast-1"}
assert resolve_bedrock_region(env) == "ap-northeast-1"
def test_defaults_to_us_east_1(self):
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
from unittest.mock import patch, MagicMock
mock_session = MagicMock()
mock_session.get_config_variable.return_value = None
@ -136,7 +136,7 @@ class TestResolveBedrocRegion:
assert resolve_bedrock_region({}) == "us-east-1"
def test_falls_back_to_botocore_profile_region(self):
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
from unittest.mock import patch, MagicMock
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
@ -144,7 +144,7 @@ class TestResolveBedrocRegion:
assert resolve_bedrock_region({}) == "eu-central-1"
def test_botocore_failure_falls_back_to_us_east_1(self):
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
from unittest.mock import patch
with _mock_botocore_session(side_effect=Exception("no botocore")):
assert resolve_bedrock_region({}) == "us-east-1"
@ -158,7 +158,7 @@ class TestConvertToolsToConverse:
"""Test OpenAI → Bedrock Converse tool definition conversion."""
def test_converts_single_tool(self):
from agent.bedrock_adapter import convert_tools_to_converse
from hermes_agent_bedrock.adapter import convert_tools_to_converse
tools = [{
"type": "function",
"function": {
@ -182,7 +182,7 @@ class TestConvertToolsToConverse:
assert "path" in spec["inputSchema"]["json"]["properties"]
def test_converts_multiple_tools(self):
from agent.bedrock_adapter import convert_tools_to_converse
from hermes_agent_bedrock.adapter import convert_tools_to_converse
tools = [
{"type": "function", "function": {"name": "tool_a", "description": "A", "parameters": {}}},
{"type": "function", "function": {"name": "tool_b", "description": "B", "parameters": {}}},
@ -193,12 +193,12 @@ class TestConvertToolsToConverse:
assert result[1]["toolSpec"]["name"] == "tool_b"
def test_empty_tools(self):
from agent.bedrock_adapter import convert_tools_to_converse
from hermes_agent_bedrock.adapter import convert_tools_to_converse
assert convert_tools_to_converse([]) == []
assert convert_tools_to_converse(None) == []
def test_missing_parameters_gets_default(self):
from agent.bedrock_adapter import convert_tools_to_converse
from hermes_agent_bedrock.adapter import convert_tools_to_converse
tools = [{"type": "function", "function": {"name": "noop", "description": "No-op"}}]
result = convert_tools_to_converse(tools)
schema = result[0]["toolSpec"]["inputSchema"]["json"]
@ -213,7 +213,7 @@ class TestConvertMessagesToConverse:
"""Test OpenAI message format → Bedrock Converse format conversion."""
def test_extracts_system_prompt(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
@ -226,7 +226,7 @@ class TestConvertMessagesToConverse:
assert msgs[0]["role"] == "user"
def test_user_message_text(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [{"role": "user", "content": "What is 2+2?"}]
system, msgs = convert_messages_to_converse(messages)
assert system is None
@ -234,7 +234,7 @@ class TestConvertMessagesToConverse:
assert msgs[0]["content"][0]["text"] == "What is 2+2?"
def test_assistant_with_tool_calls(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "user", "content": "Read the file"},
{
@ -263,7 +263,7 @@ class TestConvertMessagesToConverse:
assert tool_use_blocks[0]["toolUse"]["input"] == {"path": "/tmp/test.txt"}
def test_tool_result_becomes_user_message(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "user", "content": "Read it"},
{"role": "assistant", "content": None, "tool_calls": [{
@ -283,7 +283,7 @@ class TestConvertMessagesToConverse:
assert tr["toolResult"]["content"][0]["text"] == "file contents here"
def test_merges_consecutive_user_messages(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "user", "content": "First"},
{"role": "user", "content": "Second"},
@ -297,7 +297,7 @@ class TestConvertMessagesToConverse:
assert "Second" in texts
def test_merges_consecutive_assistant_messages(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Part 1"},
@ -308,7 +308,7 @@ class TestConvertMessagesToConverse:
assert len(assistant_msgs) == 1
def test_first_message_must_be_user(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "assistant", "content": "I'm ready"},
{"role": "user", "content": "Go"},
@ -317,7 +317,7 @@ class TestConvertMessagesToConverse:
assert msgs[0]["role"] == "user"
def test_last_message_must_be_user(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello"},
@ -326,14 +326,14 @@ class TestConvertMessagesToConverse:
assert msgs[-1]["role"] == "user"
def test_empty_content_gets_placeholder(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [{"role": "user", "content": ""}]
system, msgs = convert_messages_to_converse(messages)
# Empty string should get a space placeholder
assert msgs[0]["content"][0]["text"].strip() != "" or msgs[0]["content"][0]["text"] == " "
def test_image_data_url_converted(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [{
"role": "user",
"content": [
@ -351,7 +351,7 @@ class TestConvertMessagesToConverse:
assert image_blocks[0]["image"]["format"] == "png"
def test_multiple_system_messages_merged(self):
from agent.bedrock_adapter import convert_messages_to_converse
from hermes_agent_bedrock.adapter import convert_messages_to_converse
messages = [
{"role": "system", "content": "Rule 1"},
{"role": "system", "content": "Rule 2"},
@ -372,7 +372,7 @@ class TestNormalizeConverseResponse:
"""Test Bedrock Converse response → OpenAI format conversion."""
def test_text_response(self):
from agent.bedrock_adapter import normalize_converse_response
from hermes_agent_bedrock.adapter import normalize_converse_response
response = {
"output": {
"message": {
@ -392,7 +392,7 @@ class TestNormalizeConverseResponse:
assert result.usage.total_tokens == 15
def test_tool_use_response(self):
from agent.bedrock_adapter import normalize_converse_response
from hermes_agent_bedrock.adapter import normalize_converse_response
response = {
"output": {
"message": {
@ -422,7 +422,7 @@ class TestNormalizeConverseResponse:
assert json.loads(tool_calls[0].function.arguments) == {"path": "/tmp/test.txt"}
def test_multiple_tool_calls(self):
from agent.bedrock_adapter import normalize_converse_response
from hermes_agent_bedrock.adapter import normalize_converse_response
response = {
"output": {
"message": {
@ -441,7 +441,7 @@ class TestNormalizeConverseResponse:
assert result.choices[0].finish_reason == "tool_calls"
def test_stop_reason_mapping(self):
from agent.bedrock_adapter import _converse_stop_reason_to_openai
from hermes_agent_bedrock.adapter import _converse_stop_reason_to_openai
assert _converse_stop_reason_to_openai("end_turn") == "stop"
assert _converse_stop_reason_to_openai("stop_sequence") == "stop"
assert _converse_stop_reason_to_openai("tool_use") == "tool_calls"
@ -451,7 +451,7 @@ class TestNormalizeConverseResponse:
assert _converse_stop_reason_to_openai("unknown_reason") == "stop"
def test_empty_content(self):
from agent.bedrock_adapter import normalize_converse_response
from hermes_agent_bedrock.adapter import normalize_converse_response
response = {
"output": {"message": {"role": "assistant", "content": []}},
"stopReason": "end_turn",
@ -463,7 +463,7 @@ class TestNormalizeConverseResponse:
def test_tool_calls_override_stop_finish_reason(self):
"""When tool_calls are present but stopReason is end_turn, finish_reason should be tool_calls."""
from agent.bedrock_adapter import normalize_converse_response
from hermes_agent_bedrock.adapter import normalize_converse_response
response = {
"output": {
"message": {
@ -488,7 +488,7 @@ class TestNormalizeConverseStreamEvents:
"""Test Bedrock ConverseStream event → OpenAI format conversion."""
def test_text_stream(self):
from agent.bedrock_adapter import normalize_converse_stream_events
from hermes_agent_bedrock.adapter import normalize_converse_stream_events
events = {"stream": [
{"messageStart": {"role": "assistant"}},
{"contentBlockStart": {"contentBlockIndex": 0, "start": {}}},
@ -505,7 +505,7 @@ class TestNormalizeConverseStreamEvents:
assert result.usage.completion_tokens == 3
def test_tool_use_stream(self):
from agent.bedrock_adapter import normalize_converse_stream_events
from hermes_agent_bedrock.adapter import normalize_converse_stream_events
events = {"stream": [
{"messageStart": {"role": "assistant"}},
{"contentBlockStart": {"contentBlockIndex": 0, "start": {
@ -530,7 +530,7 @@ class TestNormalizeConverseStreamEvents:
assert json.loads(tc[0].function.arguments) == {"path": "/tmp/f"}
def test_mixed_text_and_tool_stream(self):
from agent.bedrock_adapter import normalize_converse_stream_events
from hermes_agent_bedrock.adapter import normalize_converse_stream_events
events = {"stream": [
{"messageStart": {"role": "assistant"}},
# Text block
@ -553,7 +553,7 @@ class TestNormalizeConverseStreamEvents:
assert len(result.choices[0].message.tool_calls) == 1
def test_empty_stream(self):
from agent.bedrock_adapter import normalize_converse_stream_events
from hermes_agent_bedrock.adapter import normalize_converse_stream_events
events = {"stream": [
{"messageStart": {"role": "assistant"}},
{"messageStop": {"stopReason": "end_turn"}},
@ -572,7 +572,7 @@ class TestBuildConverseKwargs:
"""Test the high-level kwargs builder for Converse API calls."""
def test_basic_kwargs(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
messages = [
{"role": "system", "content": "Be helpful."},
{"role": "user", "content": "Hi"},
@ -588,7 +588,7 @@ class TestBuildConverseKwargs:
assert len(kwargs["messages"]) >= 1
def test_includes_tools(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
tools = [{"type": "function", "function": {
"name": "test", "description": "Test", "parameters": {},
}}]
@ -600,7 +600,7 @@ class TestBuildConverseKwargs:
assert len(kwargs["toolConfig"]["tools"]) == 1
def test_includes_temperature_and_top_p(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="test-model", messages=[{"role": "user", "content": "Hi"}],
temperature=0.7, top_p=0.9,
@ -609,7 +609,7 @@ class TestBuildConverseKwargs:
assert kwargs["inferenceConfig"]["topP"] == 0.9
def test_includes_guardrail_config(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
guardrail = {
"guardrailIdentifier": "gr-123",
"guardrailVersion": "1",
@ -621,14 +621,14 @@ class TestBuildConverseKwargs:
assert kwargs["guardrailConfig"] == guardrail
def test_no_system_when_absent(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="test-model", messages=[{"role": "user", "content": "Hi"}],
)
assert "system" not in kwargs
def test_no_tool_config_when_empty(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="test-model", messages=[{"role": "user", "content": "Hi"}],
tools=[],
@ -644,7 +644,7 @@ class TestDiscoverBedrockModels:
"""Test Bedrock model discovery with mocked AWS API calls."""
def test_discovers_foundation_models(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -674,7 +674,7 @@ class TestDiscoverBedrockModels:
"inferenceProfileSummaries": [],
}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1")
assert len(models) == 2
@ -683,7 +683,7 @@ class TestDiscoverBedrockModels:
assert "amazon.nova-pro-v1:0" in ids
def test_filters_inactive_models(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -702,13 +702,13 @@ class TestDiscoverBedrockModels:
}
mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1")
assert len(models) == 0
def test_filters_non_streaming_models(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -727,13 +727,13 @@ class TestDiscoverBedrockModels:
}
mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1")
assert len(models) == 0
def test_provider_filter(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -761,14 +761,14 @@ class TestDiscoverBedrockModels:
}
mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1", provider_filter=["anthropic"])
assert len(models) == 1
assert models[0]["id"] == "anthropic.claude-v2"
def test_caches_results(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -785,7 +785,7 @@ class TestDiscoverBedrockModels:
}
mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
first = discover_bedrock_models("us-east-1")
second = discover_bedrock_models("us-east-1")
@ -794,7 +794,7 @@ class TestDiscoverBedrockModels:
assert first == second
def test_discovers_inference_profiles(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -810,14 +810,14 @@ class TestDiscoverBedrockModels:
],
}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1")
assert len(models) == 1
assert models[0]["id"] == "us.anthropic.claude-sonnet-4-6"
def test_global_profiles_sorted_first(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
mock_client = MagicMock()
@ -841,16 +841,16 @@ class TestDiscoverBedrockModels:
}],
}
with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client):
models = discover_bedrock_models("us-east-1")
assert models[0]["id"] == "global.anthropic.claude-v2"
def test_handles_api_error_gracefully(self):
from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache
from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache
reset_discovery_cache()
with patch("agent.bedrock_adapter._get_bedrock_control_client", side_effect=Exception("No creds")):
with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", side_effect=Exception("No creds")):
models = discover_bedrock_models("us-east-1")
assert models == []
@ -858,17 +858,17 @@ class TestDiscoverBedrockModels:
class TestExtractProviderFromArn:
def test_extracts_anthropic(self):
from agent.bedrock_adapter import _extract_provider_from_arn
from hermes_agent_bedrock.adapter import _extract_provider_from_arn
arn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6"
assert _extract_provider_from_arn(arn) == "anthropic"
def test_extracts_amazon(self):
from agent.bedrock_adapter import _extract_provider_from_arn
from hermes_agent_bedrock.adapter import _extract_provider_from_arn
arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0"
assert _extract_provider_from_arn(arn) == "amazon"
def test_returns_empty_for_invalid_arn(self):
from agent.bedrock_adapter import _extract_provider_from_arn
from hermes_agent_bedrock.adapter import _extract_provider_from_arn
assert _extract_provider_from_arn("not-an-arn") == ""
assert _extract_provider_from_arn("") == ""
@ -879,7 +879,7 @@ class TestExtractProviderFromArn:
class TestClientCache:
def test_reset_clears_caches(self):
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
_bedrock_control_client_cache,
reset_client_cache,
@ -899,7 +899,7 @@ class TestStreamConverseWithCallbacks:
"""Test real-time streaming with delta callbacks."""
def test_text_deltas_fire_callback(self):
from agent.bedrock_adapter import stream_converse_with_callbacks
from hermes_agent_bedrock.adapter import stream_converse_with_callbacks
deltas = []
events = {"stream": [
{"messageStart": {"role": "assistant"}},
@ -918,7 +918,7 @@ class TestStreamConverseWithCallbacks:
def test_text_deltas_suppressed_when_tool_use_present(self):
"""Text deltas should NOT fire when tool_use blocks are present."""
from agent.bedrock_adapter import stream_converse_with_callbacks
from hermes_agent_bedrock.adapter import stream_converse_with_callbacks
deltas = []
events = {"stream": [
{"messageStart": {"role": "assistant"}},
@ -945,7 +945,7 @@ class TestStreamConverseWithCallbacks:
assert len(result.choices[0].message.tool_calls) == 1
def test_tool_start_callback_fires(self):
from agent.bedrock_adapter import stream_converse_with_callbacks
from hermes_agent_bedrock.adapter import stream_converse_with_callbacks
tools_started = []
events = {"stream": [
{"messageStart": {"role": "assistant"}},
@ -965,7 +965,7 @@ class TestStreamConverseWithCallbacks:
assert tools_started == ["read_file"]
def test_interrupt_stops_processing(self):
from agent.bedrock_adapter import stream_converse_with_callbacks
from hermes_agent_bedrock.adapter import stream_converse_with_callbacks
deltas = []
call_count = {"n": 0}
events = {"stream": [
@ -990,7 +990,7 @@ class TestStreamConverseWithCallbacks:
assert len(deltas) < 3
def test_reasoning_delta_callback(self):
from agent.bedrock_adapter import stream_converse_with_callbacks
from hermes_agent_bedrock.adapter import stream_converse_with_callbacks
reasoning = []
events = {"stream": [
{"messageStart": {"role": "assistant"}},
@ -1017,7 +1017,7 @@ class TestGuardrailConfig:
"""Test that guardrail configuration is correctly passed through."""
def test_guardrail_included_in_kwargs(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
guardrail = {
"guardrailIdentifier": "gr-abc123",
"guardrailVersion": "1",
@ -1032,7 +1032,7 @@ class TestGuardrailConfig:
assert kwargs["guardrailConfig"] == guardrail
def test_no_guardrail_when_none(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="test-model",
messages=[{"role": "user", "content": "Hi"}],
@ -1041,7 +1041,7 @@ class TestGuardrailConfig:
assert "guardrailConfig" not in kwargs
def test_no_guardrail_when_empty_dict(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="test-model",
messages=[{"role": "user", "content": "Hi"}],
@ -1059,41 +1059,41 @@ class TestBedrockErrorClassification:
"""Test Bedrock-specific error classification."""
def test_context_overflow_validation_exception(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error(
"ValidationException: input is too long for model"
) == "context_overflow"
def test_context_overflow_max_tokens(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error(
"ValidationException: exceeds the maximum number of input tokens"
) == "context_overflow"
def test_context_overflow_stream_error(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error(
"ModelStreamErrorException: Input is too long"
) == "context_overflow"
def test_rate_limit_throttling(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error("ThrottlingException: Rate exceeded") == "rate_limit"
def test_rate_limit_concurrent(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error("Too many concurrent requests") == "rate_limit"
def test_overloaded_not_ready(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error("ModelNotReadyException") == "overloaded"
def test_overloaded_timeout(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error("ModelTimeoutException") == "overloaded"
def test_unknown_error(self):
from agent.bedrock_adapter import classify_bedrock_error
from hermes_agent_bedrock.adapter import classify_bedrock_error
assert classify_bedrock_error("SomeRandomError: something went wrong") == "unknown"
@ -1101,32 +1101,32 @@ class TestBedrockContextLength:
"""Test Bedrock model context length lookup."""
def test_claude_opus_4_6(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
assert get_bedrock_context_length("anthropic.claude-opus-4-6-20250514-v1:0") == 200_000
def test_claude_sonnet_versioned(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
assert get_bedrock_context_length("anthropic.claude-sonnet-4-6-20250514-v1:0") == 200_000
def test_nova_pro(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
assert get_bedrock_context_length("amazon.nova-pro-v1:0") == 300_000
def test_nova_micro(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
assert get_bedrock_context_length("amazon.nova-micro-v1:0") == 128_000
def test_unknown_model_gets_default(self):
from agent.bedrock_adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH
from hermes_agent_bedrock.adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH
assert get_bedrock_context_length("unknown.model-v1:0") == BEDROCK_DEFAULT_CONTEXT_LENGTH
def test_inference_profile_resolves(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
# Cross-region inference profiles contain the base model ID
assert get_bedrock_context_length("us.anthropic.claude-sonnet-4-6") == 200_000
def test_longest_prefix_wins(self):
from agent.bedrock_adapter import get_bedrock_context_length
from hermes_agent_bedrock.adapter import get_bedrock_context_length
# "anthropic.claude-3-5-sonnet" should match before "anthropic.claude-3"
assert get_bedrock_context_length("anthropic.claude-3-5-sonnet-20240620-v1:0") == 200_000
@ -1139,39 +1139,39 @@ class TestModelSupportsToolUse:
"""Test non-tool-calling model detection."""
def test_claude_supports_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("us.anthropic.claude-sonnet-4-6") is True
def test_nova_supports_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("us.amazon.nova-pro-v1:0") is True
def test_deepseek_v3_supports_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("deepseek.v3.2") is True
def test_llama_supports_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("us.meta.llama4-scout-17b-instruct-v1:0") is True
def test_deepseek_r1_no_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("us.deepseek.r1-v1:0") is False
def test_deepseek_r1_alt_format_no_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("deepseek-r1") is False
def test_stability_no_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("stability.stable-diffusion-xl") is False
def test_embedding_no_tools(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("cohere.embed-v4") is False
def test_unknown_model_defaults_to_true(self):
from agent.bedrock_adapter import _model_supports_tool_use
from hermes_agent_bedrock.adapter import _model_supports_tool_use
assert _model_supports_tool_use("some-future-model-v1") is True
@ -1179,7 +1179,7 @@ class TestBuildConverseKwargsToolStripping:
"""Test that tools are stripped for non-tool-calling models."""
def test_tools_included_for_claude(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}]
kwargs = build_converse_kwargs(
model="us.anthropic.claude-sonnet-4-6",
@ -1189,7 +1189,7 @@ class TestBuildConverseKwargsToolStripping:
assert "toolConfig" in kwargs
def test_tools_stripped_for_deepseek_r1(self):
from agent.bedrock_adapter import build_converse_kwargs
from hermes_agent_bedrock.adapter import build_converse_kwargs
tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}]
kwargs = build_converse_kwargs(
model="us.deepseek.r1-v1:0",
@ -1207,35 +1207,35 @@ class TestIsAnthropicBedrockModel:
"""Test Claude model detection for dual-path routing."""
def test_us_claude_sonnet(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("us.anthropic.claude-sonnet-4-6") is True
def test_global_claude_opus(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("global.anthropic.claude-opus-4-6-v1") is True
def test_bare_claude(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("anthropic.claude-haiku-4-5-20251001-v1:0") is True
def test_nova_is_not_anthropic(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("us.amazon.nova-pro-v1:0") is False
def test_deepseek_is_not_anthropic(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("deepseek.v3.2") is False
def test_llama_is_not_anthropic(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("us.meta.llama4-scout-17b-instruct-v1:0") is False
def test_mistral_is_not_anthropic(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("mistral.mistral-large-3-675b-instruct") is False
def test_eu_claude(self):
from agent.bedrock_adapter import is_anthropic_bedrock_model
from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("eu.anthropic.claude-sonnet-4-6") is True
@ -1243,22 +1243,22 @@ class TestEmptyTextBlockFix:
"""Test that empty text blocks are replaced with space placeholders."""
def test_none_content_gets_space(self):
from agent.bedrock_adapter import _convert_content_to_converse
from hermes_agent_bedrock.adapter import _convert_content_to_converse
blocks = _convert_content_to_converse(None)
assert blocks[0]["text"] == " "
def test_empty_string_gets_space(self):
from agent.bedrock_adapter import _convert_content_to_converse
from hermes_agent_bedrock.adapter import _convert_content_to_converse
blocks = _convert_content_to_converse("")
assert blocks[0]["text"] == " "
def test_whitespace_only_gets_space(self):
from agent.bedrock_adapter import _convert_content_to_converse
from hermes_agent_bedrock.adapter import _convert_content_to_converse
blocks = _convert_content_to_converse(" ")
assert blocks[0]["text"] == " "
def test_real_text_preserved(self):
from agent.bedrock_adapter import _convert_content_to_converse
from hermes_agent_bedrock.adapter import _convert_content_to_converse
blocks = _convert_content_to_converse("Hello")
assert blocks[0]["text"] == "Hello"
@ -1271,7 +1271,7 @@ class TestInvalidateRuntimeClient:
"""Per-region eviction used to discard dead/stale bedrock-runtime clients."""
def test_evicts_only_the_target_region(self):
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
invalidate_runtime_client,
reset_client_cache,
@ -1287,7 +1287,7 @@ class TestInvalidateRuntimeClient:
assert _bedrock_runtime_client_cache["us-west-2"] == "live-client"
def test_returns_false_when_region_not_cached(self):
from agent.bedrock_adapter import invalidate_runtime_client, reset_client_cache
from hermes_agent_bedrock.adapter import invalidate_runtime_client, reset_client_cache
reset_client_cache()
assert invalidate_runtime_client("eu-west-1") is False
@ -1297,27 +1297,27 @@ class TestIsStaleConnectionError:
def test_detects_botocore_connection_closed_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
from botocore.exceptions import ConnectionClosedError
exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_endpoint_connection_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
from botocore.exceptions import EndpointConnectionError
exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_read_timeout(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
from botocore.exceptions import ReadTimeoutError
exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_urllib3_protocol_error(self):
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
from urllib3.exceptions import ProtocolError
exc = ProtocolError("Connection broken")
assert is_stale_connection_error(exc) is True
@ -1325,7 +1325,7 @@ class TestIsStaleConnectionError:
def test_detects_library_internal_assertion_error(self):
"""A bare AssertionError raised from inside urllib3/botocore signals
a corrupted connection-pool invariant and should trigger eviction."""
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
# Fabricate an AssertionError whose traceback's last frame belongs
# to a module named "urllib3.connectionpool". We do this by exec'ing
@ -1341,7 +1341,7 @@ class TestIsStaleConnectionError:
def test_detects_botocore_internal_assertion_error(self):
"""Same as above but for a frame inside the botocore namespace."""
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
fake_globals = {"__name__": "botocore.httpsession"}
try:
exec("def _boom():\n assert False\n_boom()", fake_globals)
@ -1353,14 +1353,14 @@ class TestIsStaleConnectionError:
def test_ignores_application_assertion_error(self):
"""AssertionError from application code (not urllib3/botocore) should
NOT be classified as stale those are real test/code bugs."""
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
try:
assert False, "test-only" # noqa: B011
except AssertionError as exc:
assert is_stale_connection_error(exc) is False
def test_ignores_unrelated_exceptions(self):
from agent.bedrock_adapter import is_stale_connection_error
from hermes_agent_bedrock.adapter import is_stale_connection_error
assert is_stale_connection_error(ValueError("bad input")) is False
assert is_stale_connection_error(KeyError("missing")) is False
@ -1372,7 +1372,7 @@ class TestCallConverseInvalidatesOnStaleError:
def test_converse_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
call_converse,
reset_client_cache,
@ -1399,7 +1399,7 @@ class TestCallConverseInvalidatesOnStaleError:
def test_converse_stream_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
call_converse_stream,
reset_client_cache,
@ -1425,7 +1425,7 @@ class TestCallConverseInvalidatesOnStaleError:
def test_converse_does_not_evict_on_non_stale_error(self):
"""Non-stale errors (e.g. ValidationException) leave the client cache alone."""
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
call_converse,
reset_client_cache,
@ -1452,7 +1452,7 @@ class TestCallConverseInvalidatesOnStaleError:
)
def test_converse_leaves_successful_client_in_cache(self):
from agent.bedrock_adapter import (
from hermes_agent_bedrock.adapter import (
_bedrock_runtime_client_cache,
call_converse,
reset_client_cache,

View file

@ -282,7 +282,7 @@ class TestPackaging:
# us.anthropic.claude-sonnet-4-5-20250929-v1:0
# apac.anthropic.claude-haiku-4-5
#
# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens
# ``hermes_agent_anthropic.adapter.normalize_model_name`` converts dots to hyphens
# unless the caller opts in via ``preserve_dots=True``. Before this fix,
# ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock``
# provider, so Claude-on-Bedrock requests went out with
@ -360,14 +360,14 @@ class TestBedrockModelNameNormalization:
def test_global_anthropic_inference_profile_preserved(self):
"""The reporter's exact model ID."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"global.anthropic.claude-opus-4-7", preserve_dots=True
) == "global.anthropic.claude-opus-4-7"
def test_us_anthropic_dated_inference_profile_preserved(self):
"""Regional + dated Sonnet inference profile."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
preserve_dots=True,
@ -375,7 +375,7 @@ class TestBedrockModelNameNormalization:
def test_apac_anthropic_haiku_inference_profile_preserved(self):
"""APAC inference profile — same structural-dot shape."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"apac.anthropic.claude-haiku-4-5", preserve_dots=True
) == "apac.anthropic.claude-haiku-4-5"
@ -385,7 +385,7 @@ class TestBedrockModelNameNormalization:
always returned unmangled -- ``preserve_dots`` is irrelevant for
these IDs because the dots are namespace separators, not version
separators. Regression for #12295."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"global.anthropic.claude-opus-4-7", preserve_dots=False
) == "global.anthropic.claude-opus-4-7"
@ -395,7 +395,7 @@ class TestBedrockModelNameNormalization:
(e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
vendor separators and must also survive intact under
``preserve_dots=True``."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
preserve_dots=True,
@ -410,7 +410,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd:
regression for the reporter's HTTP 400."""
def test_bedrock_inference_profile_survives_build_kwargs(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="global.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -429,7 +429,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd:
even without ``preserve_dots=True`` -- the prefix auto-detection
in ``normalize_model_name`` is the load-bearing piece.
Regression for #12295."""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="global.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -447,47 +447,47 @@ class TestBedrockModelIdDetection:
regardless of ``preserve_dots``. Regression for #12295."""
def test_bare_bedrock_id_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("anthropic.claude-opus-4-7") is True
def test_regional_us_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("us.anthropic.claude-sonnet-4-5-v1:0") is True
def test_regional_global_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("global.anthropic.claude-opus-4-7") is True
def test_regional_eu_prefix_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("eu.anthropic.claude-sonnet-4-6") is True
def test_openrouter_format_not_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4.6") is False
def test_bare_claude_not_detected(self):
from agent.anthropic_adapter import _is_bedrock_model_id
from hermes_agent_anthropic import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4-7") is False
def test_bare_bedrock_id_preserved_without_flag(self):
"""The primary bug from #12295: ``anthropic.claude-opus-4-7``
sent to bedrock-mantle via auxiliary clients that don't pass
``preserve_dots=True``."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name(
"anthropic.claude-opus-4-7", preserve_dots=False
) == "anthropic.claude-opus-4-7"
def test_openrouter_dots_still_converted(self):
"""Non-Bedrock dotted model names must still be converted."""
from agent.anthropic_adapter import normalize_model_name
from hermes_agent_anthropic import normalize_model_name
assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6"
def test_bare_bedrock_id_survives_build_kwargs(self):
"""End-to-end: bare Bedrock ID through ``build_anthropic_kwargs``
without ``preserve_dots=True`` -- the auxiliary client path."""
from agent.anthropic_adapter import build_anthropic_kwargs
from hermes_agent_anthropic import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -517,7 +517,7 @@ class TestAuxiliaryClientBedrockResolution:
monkeypatch.setenv("AWS_REGION", "us-west-2")
mock_anthropic_bedrock = MagicMock()
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=mock_anthropic_bedrock):
from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None)
@ -533,7 +533,7 @@ class TestAuxiliaryClientBedrockResolution:
def test_bedrock_returns_none_without_credentials(self, monkeypatch):
"""Without AWS credentials, Bedrock should return (None, None) gracefully."""
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=False):
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("bedrock", None)
@ -546,7 +546,7 @@ class TestAuxiliaryClientBedrockResolution:
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
client, _ = resolve_provider_client("bedrock", None)
@ -559,7 +559,7 @@ class TestAuxiliaryClientBedrockResolution:
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client(
@ -573,7 +573,7 @@ class TestAuxiliaryClientBedrockResolution:
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None, async_mode=True)
@ -586,7 +586,7 @@ class TestAuxiliaryClientBedrockResolution:
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client("bedrock", None)

View file

@ -71,8 +71,8 @@ class TestProviderModelIdsBedrock:
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result
@ -83,10 +83,10 @@ class TestProviderModelIdsBedrock:
"""Different regions produce different model ID prefixes (eu.* vs us.*)."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover):
with patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
eu_result = provider_model_ids("bedrock")
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
with patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="us-east-1"):
us_result = provider_model_ids("bedrock")
assert all(m.startswith("eu.") for m in eu_result)
@ -97,8 +97,8 @@ class TestProviderModelIdsBedrock:
"""When discover_bedrock_models() returns [], fall back to curated static list."""
from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=[]), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
# Should fall back to static table (may be empty or populated depending on
@ -109,9 +109,9 @@ class TestProviderModelIdsBedrock:
"""When discover_bedrock_models() raises, fall back gracefully."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models",
with patch("hermes_agent_bedrock.adapter.discover_bedrock_models",
side_effect=Exception("boto3 not installed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert isinstance(result, list) # no crash
@ -122,8 +122,8 @@ class TestProviderModelIdsBedrock:
_expected_ids = [m["id"] for m in _US_MODELS]
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="us-east-1"):
for alias in ("aws", "aws-bedrock", "amazon-bedrock"):
result = provider_model_ids(alias)
assert result == _expected_ids, \
@ -144,9 +144,9 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -158,9 +158,9 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -177,9 +177,9 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -192,9 +192,9 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -212,7 +212,7 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=False):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -236,7 +236,7 @@ class TestListAuthenticatedProvidersBedrock:
calls["has_aws_credentials"] += 1
return False
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", side_effect=_has_aws_credentials):
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
assert calls["has_aws_credentials"] == 0
@ -248,10 +248,10 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models",
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models",
side_effect=Exception("API call failed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
# Should not raise — bedrock entry may or may not appear depending on
@ -264,9 +264,9 @@ class TestListAuthenticatedProvidersBedrock:
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock_entries = [p for p in providers if p["slug"] == "bedrock"]
@ -289,8 +289,8 @@ class TestBedrockRegionRouting:
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \
_mock_botocore_session(return_value=mock_session):
providers = list_authenticated_providers(current_provider="bedrock")
@ -306,8 +306,8 @@ class TestBedrockRegionRouting:
monkeypatch.setenv("AWS_REGION", "us-east-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \
patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -318,7 +318,7 @@ class TestBedrockRegionRouting:
def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch):
"""AWS_REGION env var wins over botocore profile region."""
from agent.bedrock_adapter import resolve_bedrock_region
from hermes_agent_bedrock.adapter import resolve_bedrock_region
monkeypatch.setenv("AWS_REGION", "us-west-2")

View file

@ -32,3 +32,9 @@ copilot_acp = CopilotACPProfile(
)
register_provider(copilot_acp)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -56,3 +56,9 @@ copilot = CopilotProfile(
)
register_provider(copilot)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -66,3 +66,9 @@ custom = CustomProfile(
)
register_provider(custom)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -98,3 +98,9 @@ deepseek = DeepSeekProfile(
)
register_provider(deepseek)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -70,3 +70,9 @@ google_gemini_cli = GeminiProfile(
register_provider(gemini)
register_provider(google_gemini_cli)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -29,3 +29,9 @@ gmi = ProviderProfile(
)
register_provider(gmi)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -18,3 +18,9 @@ huggingface = ProviderProfile(
)
register_provider(huggingface)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -12,3 +12,9 @@ kilocode = ProviderProfile(
)
register_provider(kilocode)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -69,3 +69,9 @@ kimi_cn = KimiProfile(
register_provider(kimi)
register_provider(kimi_cn)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -43,3 +43,9 @@ minimax_oauth = ProviderProfile(
register_provider(minimax)
register_provider(minimax_cn)
register_provider(minimax_oauth)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

View file

@ -52,3 +52,9 @@ nous = NousProfile(
)
register_provider(nous)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass

Some files were not shown because too many files have changed in this diff Show more