mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
wip wip wip pluginify everything
This commit is contained in:
parent
4117fc3645
commit
10421ec9ee
308 changed files with 8019 additions and 5246 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -41,7 +41,7 @@ agent-browser/
|
|||
privvy*
|
||||
images/
|
||||
__pycache__/
|
||||
hermes_agent.egg-info/
|
||||
*.egg-info
|
||||
wandb/
|
||||
testlogs
|
||||
|
||||
|
|
|
|||
201
AGENTS.md
201
AGENTS.md
|
|
@ -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.5–1.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
183
_build_backend.py
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:]}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
384
agent/plugin_registries.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
39
cli.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
116
gateway/run.py
116
gateway/run.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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'}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'")
|
||||
|
|
|
|||
233
nix/checks.nix
233
nix/checks.nix
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"; };
|
||||
|
|
|
|||
7
plugins/dashboard/__init__.py
Normal file
7
plugins/dashboard/__init__.py
Normal 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)
|
||||
6
plugins/dashboard/hermes_agent_dashboard/__init__.py
Normal file
6
plugins/dashboard/hermes_agent_dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Hermes Agent web dashboard."""
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
|
||||
pass
|
||||
6
plugins/dashboard/plugin.yaml
Normal file
6
plugins/dashboard/plugin.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
name: dashboard
|
||||
version: 0.1.0
|
||||
description: Web dashboard (FastAPI + uvicorn)
|
||||
kind: backend
|
||||
provides_tools: ["dashboard"]
|
||||
provides_hooks: []
|
||||
20
plugins/dashboard/pyproject.toml
Normal file
20
plugins/dashboard/pyproject.toml
Normal 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*"]
|
||||
7
plugins/image_gen/fal_pkg/__init__.py
Normal file
7
plugins/image_gen/fal_pkg/__init__.py
Normal 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)
|
||||
36
plugins/image_gen/fal_pkg/hermes_agent_fal/__init__.py
Normal file
36
plugins/image_gen/fal_pkg/hermes_agent_fal/__init__.py
Normal 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,
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
6
plugins/image_gen/fal_pkg/plugin.yaml
Normal file
6
plugins/image_gen/fal_pkg/plugin.yaml
Normal 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: []
|
||||
19
plugins/image_gen/fal_pkg/pyproject.toml
Normal file
19
plugins/image_gen/fal_pkg/pyproject.toml
Normal 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*"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
19
plugins/memory/hindsight/pyproject.toml
Normal file
19
plugins/memory/hindsight/pyproject.toml
Normal 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*"]
|
||||
|
|
@ -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. "
|
||||
|
|
|
|||
19
plugins/memory/honcho/pyproject.toml
Normal file
19
plugins/memory/honcho/pyproject.toml
Normal 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*"]
|
||||
|
|
@ -41,3 +41,9 @@ vercel = VercelAIGatewayProfile(
|
|||
)
|
||||
|
||||
register_provider(vercel)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,3 +11,9 @@ alibaba = ProviderProfile(
|
|||
)
|
||||
|
||||
register_provider(alibaba)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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)
|
||||
20
plugins/model-providers/anthropic/pyproject.toml
Normal file
20
plugins/model-providers/anthropic/pyproject.toml
Normal 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*"]
|
||||
108
plugins/model-providers/anthropic/tests/conftest.py
Normal file
108
plugins/model-providers/anthropic/tests/conftest.py
Normal 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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"}],
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"},
|
||||
|
|
@ -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"},
|
||||
|
|
@ -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",
|
||||
|
|
@ -11,3 +11,9 @@ arcee = ProviderProfile(
|
|||
)
|
||||
|
||||
register_provider(arcee)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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:
|
||||
19
plugins/model-providers/azure-foundry/pyproject.toml
Normal file
19
plugins/model-providers/azure-foundry/pyproject.toml
Normal 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*"]
|
||||
|
|
@ -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 = {}
|
||||
|
||||
|
|
@ -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={
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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.
|
||||
19
plugins/model-providers/bedrock/pyproject.toml
Normal file
19
plugins/model-providers/bedrock/pyproject.toml
Normal 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*"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -32,3 +32,9 @@ copilot_acp = CopilotACPProfile(
|
|||
)
|
||||
|
||||
register_provider(copilot_acp)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -56,3 +56,9 @@ copilot = CopilotProfile(
|
|||
)
|
||||
|
||||
register_provider(copilot)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -66,3 +66,9 @@ custom = CustomProfile(
|
|||
)
|
||||
|
||||
register_provider(custom)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -98,3 +98,9 @@ deepseek = DeepSeekProfile(
|
|||
)
|
||||
|
||||
register_provider(deepseek)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -29,3 +29,9 @@ gmi = ProviderProfile(
|
|||
)
|
||||
|
||||
register_provider(gmi)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -18,3 +18,9 @@ huggingface = ProviderProfile(
|
|||
)
|
||||
|
||||
register_provider(huggingface)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,9 @@ kilocode = ProviderProfile(
|
|||
)
|
||||
|
||||
register_provider(kilocode)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue