Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c3399b81 | ||
|
|
9ee6f7be36 | ||
|
|
cec9df17ca | ||
|
|
8f2631dc97 | ||
|
|
3a2c03061c | ||
|
|
781604ce4c | ||
|
|
0dc0c5ea6b | ||
|
|
3845d86b93 | ||
|
|
d473e7c938 | ||
|
|
91b174038c | ||
|
|
8055d0f092 | ||
|
|
9405cdc8dd | ||
|
|
08c0b22417 | ||
|
|
38c4f8c371 | ||
|
|
a1cb5fa2c7 | ||
|
|
45b00bb49a | ||
|
|
8836b3a113 | ||
|
|
6312dd8c3a | ||
|
|
30a0d5bc9e | ||
|
|
aa283d1e4f | ||
|
|
2fc2280e63 | ||
|
|
27a2c4f36f | ||
|
|
1cb850b674 | ||
|
|
b6ed3913d2 | ||
|
|
4de8009ce4 | ||
|
|
1596bb287e | ||
|
|
90b3c54de9 | ||
|
|
5641ae6469 | ||
|
|
549a69a925 | ||
|
|
3f0d44af8a | ||
|
|
eff4626747 | ||
|
|
175885218e | ||
|
|
119390a2a1 | ||
|
|
3625dbb844 | ||
|
|
aef04b2b53 | ||
|
|
a2d3cff53f | ||
|
|
ee0a9bf7c7 | ||
|
|
b922e3ff93 | ||
|
|
053969fd53 | ||
|
|
988cf1743b | ||
|
|
03bdeaa876 | ||
|
|
d86710528a | ||
|
|
6891e05e78 | ||
|
|
0673638560 | ||
|
|
ae9dfa510e | ||
|
|
7379f17556 | ||
|
|
0563ab0652 | ||
|
|
e46e4bcf47 | ||
|
|
3183b2e28c | ||
|
|
a4c18f65d4 | ||
|
|
b6294ea9f1 | ||
|
|
d04b3c193e | ||
|
|
5cd0673217 | ||
|
|
6bc309baf2 | ||
|
|
6928692cec | ||
|
|
66265a0571 |
@@ -3,11 +3,9 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,7 +18,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -200,22 +200,3 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
plugin-isolation:
|
||||
# Enforce that core code and core tests never import from plugin packages.
|
||||
# Core must interact with plugins exclusively through the registry layer.
|
||||
# See scripts/check_no_plugin_imports_in_core.py for the rule list.
|
||||
name: Plugin isolation (blocking)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run plugin isolation checker
|
||||
run: python scripts/check_no_plugin_imports_in_core.py
|
||||
|
||||
@@ -3,15 +3,9 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -27,8 +21,44 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -147,10 +177,24 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -211,3 +255,16 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
+3
-2
@@ -48,7 +48,7 @@ agent-browser/
|
||||
privvy*
|
||||
images/
|
||||
__pycache__/
|
||||
*.egg-info
|
||||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
|
||||
@@ -87,7 +87,8 @@ website/static/api/skills-meta.json
|
||||
models-dev-upstream/
|
||||
hermes_cli/tui_dist/*
|
||||
hermes_cli/scripts/
|
||||
docs/superpowers/*# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
|
||||
docs/superpowers/*
|
||||
# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
|
||||
# also created in-repo when an agent operates in this checkout). Plans, audit
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
@@ -29,9 +29,7 @@ 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)
|
||||
@@ -41,20 +39,16 @@ 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 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
|
||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||
│ ├── 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
|
||||
│ └── <others>/ # dashboard, google_meet, spotify, strike-freedom-cockpit, ...
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # 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`
|
||||
@@ -492,102 +486,9 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
|
||||
|
||||
## Plugins
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
|
||||
|
||||
@@ -630,14 +531,9 @@ 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, 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.
|
||||
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.
|
||||
|
||||
**No new in-tree memory providers (policy, May 2026):** the set of
|
||||
built-in memory providers under `plugins/memory/` is closed. New memory
|
||||
@@ -1115,41 +1011,40 @@ def profile_env(tmp_path, monkeypatch):
|
||||
|
||||
## Testing
|
||||
|
||||
**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.
|
||||
**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).
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
For a **single test file or specific test**, bare `pytest` is fine:
|
||||
### Subprocess-per-test isolation
|
||||
|
||||
```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
|
||||
```
|
||||
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.
|
||||
|
||||
Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning
|
||||
from `conftest.py` telling you to use the script instead.
|
||||
Implementation notes:
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
### Why the wrapper (and why the old "just call pytest" doesn't work)
|
||||
|
||||
@@ -1161,13 +1056,31 @@ 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 |
|
||||
| File isolation | Shared interpreter — state leaks between files | One subprocess per file |
|
||||
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
Always run the full suite via `scripts/run_tests.sh` before pushing changes.
|
||||
### 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.
|
||||
|
||||
### Don't write change-detector tests
|
||||
|
||||
|
||||
+6
-5
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
@@ -121,11 +121,12 @@ hermes chat -q "Hello"
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md
|
||||
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
|
||||
scripts/run_tests.sh
|
||||
|
||||
# For a single file or specific test, bare pytest is also fine:
|
||||
# python -m pytest tests/agent/test_foo.py -q
|
||||
# Alternative (activate the venv first). The wrapper is still recommended
|
||||
# for parity with GitHub Actions before you open a PR:
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
@@ -856,7 +857,7 @@ refactor/description # Code restructuring
|
||||
|
||||
### Before submitting
|
||||
|
||||
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI — hermetic env + per-file process isolation)
|
||||
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
|
||||
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.
|
||||
|
||||
+1
-1
@@ -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]"
|
||||
scripts/run_tests.sh
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""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,9 +6,7 @@ from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
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 agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
||||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
@@ -178,7 +176,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
token = (resolve_anthropic_token() or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
if _is_oauth_token is not None and not _is_oauth_token(token):
|
||||
if not _is_oauth_token(token):
|
||||
return AccountUsageSnapshot(
|
||||
provider="anthropic",
|
||||
source="oauth_usage_api",
|
||||
|
||||
+28
-32
@@ -401,7 +401,7 @@ def init_agent(
|
||||
agent.status_callback = status_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
||||
# Tool execution state — allows _vprint during tool execution
|
||||
# even when stream consumers are registered (no tokens streaming then)
|
||||
agent._executing_tools = False
|
||||
@@ -434,12 +434,12 @@ def init_agent(
|
||||
# their tids explicitly.
|
||||
agent._tool_worker_threads: set[int] = set()
|
||||
agent._tool_worker_threads_lock = threading.Lock()
|
||||
|
||||
|
||||
# Subagent delegation state
|
||||
agent._delegate_depth = 0 # 0 = top-level agent, incremented for children
|
||||
agent._active_children = [] # Running child AIAgents (for interrupt propagation)
|
||||
agent._active_children_lock = threading.Lock()
|
||||
|
||||
|
||||
# Store OpenRouter provider preferences
|
||||
agent.providers_allowed = providers_allowed
|
||||
agent.providers_ignored = providers_ignored
|
||||
@@ -452,7 +452,7 @@ def init_agent(
|
||||
# Store toolset filtering options
|
||||
agent.enabled_toolsets = enabled_toolsets
|
||||
agent.disabled_toolsets = disabled_toolsets
|
||||
|
||||
|
||||
# Model response configuration
|
||||
agent.max_tokens = max_tokens # None = use model default
|
||||
agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
|
||||
@@ -460,7 +460,7 @@ def init_agent(
|
||||
agent.request_overrides = dict(request_overrides or {})
|
||||
agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns
|
||||
agent._force_ascii_payload = False
|
||||
|
||||
|
||||
# Anthropic prompt caching: auto-enabled for Claude models on native
|
||||
# Anthropic, OpenRouter, and third-party gateways that speak the
|
||||
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
|
||||
@@ -532,7 +532,7 @@ def init_agent(
|
||||
# console. Any future noise reduction belongs at the
|
||||
# handler level inside hermes_logging.py, not here.
|
||||
pass
|
||||
|
||||
|
||||
# Internal stream callback (set during streaming TTS).
|
||||
# Initialized here so _vprint can reference it before run_conversation.
|
||||
agent._stream_callback = None
|
||||
@@ -582,14 +582,12 @@ def init_agent(
|
||||
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
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")
|
||||
from agent.anthropic_adapter import build_anthropic_client, 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:
|
||||
build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client")
|
||||
from agent.anthropic_adapter import 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
|
||||
@@ -643,8 +641,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.
|
||||
_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
|
||||
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
|
||||
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
agent.client = None
|
||||
@@ -656,10 +654,9 @@ 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.plugin_registries import registries
|
||||
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
|
||||
if is_token_provider and is_token_provider(effective_key):
|
||||
if 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:]}")
|
||||
@@ -869,11 +866,10 @@ 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.plugin_registries import registries
|
||||
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
|
||||
key_used = client_kwargs.get("api_key", "none")
|
||||
if is_token_provider and is_token_provider(key_used):
|
||||
if 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:]}")
|
||||
@@ -881,7 +877,7 @@ def init_agent(
|
||||
print("⚠️ Warning: API key appears invalid or missing")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||
|
||||
|
||||
# Provider fallback chain — ordered list of backup providers tried
|
||||
# when the primary is exhausted (rate-limit, overload, connection
|
||||
# failure). Supports both legacy single-dict ``fallback_model`` and
|
||||
@@ -913,7 +909,7 @@ def init_agent(
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
quiet_mode=agent.quiet_mode,
|
||||
)
|
||||
|
||||
|
||||
# Show tool configuration and store valid tool names for validation
|
||||
agent.valid_tool_names = set()
|
||||
if agent.tools:
|
||||
@@ -946,16 +942,16 @@ def init_agent(
|
||||
missing_reqs = [name for name, available in requirements.items() if not available]
|
||||
if missing_reqs:
|
||||
print(f"⚠️ Some tools may not work due to missing requirements: {missing_reqs}")
|
||||
|
||||
|
||||
# Show trajectory saving status
|
||||
if agent.save_trajectories and not agent.quiet_mode:
|
||||
print("📝 Trajectory saving enabled")
|
||||
|
||||
|
||||
# Show ephemeral system prompt status
|
||||
if agent.ephemeral_system_prompt and not agent.quiet_mode:
|
||||
prompt_preview = agent.ephemeral_system_prompt[:60] + "..." if len(agent.ephemeral_system_prompt) > 60 else agent.ephemeral_system_prompt
|
||||
print(f"🔒 Ephemeral system prompt: '{prompt_preview}' (not saved to trajectories)")
|
||||
|
||||
|
||||
# Show prompt caching status
|
||||
if agent._use_prompt_caching and not agent.quiet_mode:
|
||||
if agent._use_native_cache_layout and agent.provider == "anthropic":
|
||||
@@ -965,7 +961,7 @@ def init_agent(
|
||||
else:
|
||||
source = "Claude via OpenRouter"
|
||||
print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)")
|
||||
|
||||
|
||||
# Session logging setup - auto-save conversation trajectories for debugging
|
||||
agent.session_start = datetime.now()
|
||||
if session_id:
|
||||
@@ -1005,7 +1001,7 @@ def init_agent(
|
||||
pass
|
||||
# logs_dir is retained unconditionally for request_dump_*.json (debug
|
||||
# breadcrumb path written by agent_runtime_helpers.dump_api_request_debug).
|
||||
|
||||
|
||||
# Track conversation messages for session logging
|
||||
agent._session_messages: List[Dict[str, Any]] = []
|
||||
# Responses encrypted reasoning replay state. Some OpenAI-compatible
|
||||
@@ -1017,10 +1013,10 @@ def init_agent(
|
||||
agent._codex_reasoning_replay_enabled = True
|
||||
agent._memory_write_origin = "assistant_tool"
|
||||
agent._memory_write_context = "foreground"
|
||||
|
||||
|
||||
# Cached system prompt -- built once per session, only rebuilt on compression
|
||||
agent._cached_system_prompt: Optional[str] = None
|
||||
|
||||
|
||||
# Filesystem checkpoint manager (transparent — not a tool)
|
||||
from tools.checkpoint_manager import CheckpointManager
|
||||
agent._checkpoint_mgr = CheckpointManager(
|
||||
@@ -1029,7 +1025,7 @@ def init_agent(
|
||||
max_total_size_mb=checkpoint_max_total_size_mb,
|
||||
max_file_size_mb=checkpoint_max_file_size_mb,
|
||||
)
|
||||
|
||||
|
||||
# SQLite session store (optional -- provided by CLI or gateway)
|
||||
agent._session_db = session_db
|
||||
agent._parent_session_id = parent_session_id
|
||||
@@ -1040,11 +1036,11 @@ def init_agent(
|
||||
"reasoning_config": reasoning_config,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
|
||||
# In-memory todo list for task planning (one per agent/session)
|
||||
from tools.todo_tool import TodoStore
|
||||
agent._todo_store = TodoStore()
|
||||
|
||||
|
||||
# Load config once for memory, skills, and compression sections
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_agent_config
|
||||
@@ -1086,7 +1082,7 @@ def init_agent(
|
||||
agent._memory_store.load_from_disk()
|
||||
except Exception:
|
||||
pass # Memory is optional -- don't break agent init
|
||||
|
||||
|
||||
|
||||
|
||||
# Memory provider plugin (external — one at a time, alongside built-in)
|
||||
@@ -1557,7 +1553,7 @@ def init_agent(
|
||||
agent.session_estimated_cost_usd = 0.0
|
||||
agent.session_cost_status = "unknown"
|
||||
agent.session_cost_source = "none"
|
||||
|
||||
|
||||
# ── Ollama num_ctx injection ──
|
||||
# Ollama defaults to 2048 context regardless of the model's capabilities.
|
||||
# When running against an Ollama server, detect the model's max context
|
||||
|
||||
@@ -759,8 +759,7 @@ def try_recover_primary_transport(
|
||||
agent.api_key = rt["api_key"]
|
||||
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
from agent.anthropic_adapter import 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(
|
||||
@@ -924,8 +923,7 @@ def restore_primary_runtime(agent) -> bool:
|
||||
|
||||
# ── Rebuild client for the primary provider ──
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
from agent.anthropic_adapter import 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(
|
||||
@@ -1431,10 +1429,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
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")
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
"""Anthropic auxiliary client wrappers — core module, no SDK dependency.
|
||||
|
||||
Provides OpenAI-client-compatible shims over native Anthropic SDK clients,
|
||||
so auxiliary tasks (compression, vision, web extract, etc.) can call
|
||||
``client.chat.completions.create()`` regardless of the underlying SDK.
|
||||
|
||||
The wrapper classes themselves never import the anthropic SDK. They delegate
|
||||
wire-format conversion to :mod:`agent.anthropic_format` and response
|
||||
normalization to the ``anthropic_messages`` transport registered in
|
||||
:mod:`agent.transports`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.anthropic_format import (
|
||||
build_anthropic_kwargs,
|
||||
_forbids_sampling_params,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter: Anthropic SDK → OpenAI-compatible completions.create()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AnthropicCompletionsAdapter:
|
||||
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
|
||||
self._client = real_client
|
||||
self._model = model
|
||||
self._is_oauth = is_oauth
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.transports import get_transport
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
|
||||
if _skip_mt:
|
||||
max_tokens = None
|
||||
else:
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
temperature = kwargs.get("temperature")
|
||||
|
||||
normalized_tool_choice = None
|
||||
if isinstance(tool_choice, str):
|
||||
normalized_tool_choice = tool_choice
|
||||
elif isinstance(tool_choice, dict):
|
||||
choice_type = str(tool_choice.get("type", "")).lower()
|
||||
if choice_type == "function":
|
||||
normalized_tool_choice = tool_choice.get("function", {}).get("name")
|
||||
elif choice_type in {"auto", "required", "none"}:
|
||||
normalized_tool_choice = choice_type
|
||||
|
||||
anthropic_kwargs = build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
)
|
||||
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
|
||||
# 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:
|
||||
if not _forbids_sampling_params(model):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=_nr.tool_calls,
|
||||
reasoning=_nr.reasoning,
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
|
||||
usage = None
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
||||
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
||||
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
model=model,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class _AnthropicChatShim:
|
||||
def __init__(self, adapter: _AnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AnthropicAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
|
||||
self._real_client = real_client
|
||||
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
|
||||
self.chat = _AnthropicChatShim(adapter)
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
def close(self):
|
||||
close_fn = getattr(self._real_client, "close", None)
|
||||
if callable(close_fn):
|
||||
close_fn()
|
||||
|
||||
|
||||
class _AsyncAnthropicCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncAnthropicChatShim:
|
||||
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncAnthropicAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: AnthropicAuxiliaryClient):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncAnthropicChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
# Mirror _real_client so cache eviction on a poisoned underlying
|
||||
# client also drops this entry.
|
||||
self._real_client = sync_wrapper._real_client
|
||||
+449
-115
@@ -106,41 +106,6 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core anthropic wire-format modules (no SDK dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from agent.anthropic_aux import ( # noqa: F401
|
||||
AnthropicAuxiliaryClient,
|
||||
AsyncAnthropicAuxiliaryClient,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-registry helper — access *plugin-provided* anthropic services
|
||||
# (resolve.py functions: maybe_wrap_anthropic, is_anthropic_compat_endpoint, etc.)
|
||||
# Wire-format code (message conversion, aux client wrappers) lives in core
|
||||
# and is imported directly above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _anthropic_plugin_service(name: str):
|
||||
"""Lazy accessor for anthropic plugin resolve services.
|
||||
|
||||
Only the SDK-dependent orchestration (maybe_wrap_anthropic,
|
||||
is_anthropic_compat_endpoint, convert_openai_images_to_anthropic) lives
|
||||
in the plugin. Core accesses it through
|
||||
``registries.get_provider_service("anthropic", name)`` so that:
|
||||
- Core never imports from a plugin package directly.
|
||||
- The plugin need only be installed when the user actually uses it.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
svc = registries.get_provider_service("anthropic", name)
|
||||
if svc is None:
|
||||
raise ImportError(
|
||||
f"anthropic plugin service {name!r} not available — "
|
||||
f"the hermes_agent_anthropic package may not be installed"
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
|
||||
"""Return False instead of raising when a patched symbol is not a type."""
|
||||
@@ -452,6 +417,7 @@ auxiliary_is_nous: bool = False
|
||||
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
|
||||
# Codex OAuth endpoint used when a caller explicitly requests
|
||||
@@ -990,6 +956,253 @@ class AsyncCodexAuxiliaryClient:
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
class _AnthropicCompletionsAdapter:
|
||||
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
|
||||
self._client = real_client
|
||||
self._model = model
|
||||
self._is_oauth = is_oauth
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
from agent.transports import get_transport
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
|
||||
if _skip_mt:
|
||||
max_tokens = None
|
||||
else:
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
temperature = kwargs.get("temperature")
|
||||
|
||||
normalized_tool_choice = None
|
||||
if isinstance(tool_choice, str):
|
||||
normalized_tool_choice = tool_choice
|
||||
elif isinstance(tool_choice, dict):
|
||||
choice_type = str(tool_choice.get("type", "")).lower()
|
||||
if choice_type == "function":
|
||||
normalized_tool_choice = tool_choice.get("function", {}).get("name")
|
||||
elif choice_type in {"auto", "required", "none"}:
|
||||
normalized_tool_choice = choice_type
|
||||
|
||||
anthropic_kwargs = build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
)
|
||||
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
|
||||
# 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):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
|
||||
# .function.arguments) via properties, so no wrapping needed.
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=_nr.tool_calls,
|
||||
reasoning=_nr.reasoning,
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
|
||||
usage = None
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
||||
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
||||
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
model=model,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class _AnthropicChatShim:
|
||||
def __init__(self, adapter: _AnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AnthropicAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
|
||||
self._real_client = real_client
|
||||
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
|
||||
self.chat = _AnthropicChatShim(adapter)
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
def close(self):
|
||||
close_fn = getattr(self._real_client, "close", None)
|
||||
if callable(close_fn):
|
||||
close_fn()
|
||||
|
||||
|
||||
class _AsyncAnthropicCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncAnthropicChatShim:
|
||||
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncAnthropicAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncAnthropicChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
# See AsyncCodexAuxiliaryClient: mirror _real_client so cache
|
||||
# eviction on a poisoned underlying client also drops this entry.
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
|
||||
Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the
|
||||
auxiliary client and the main agent stay in sync on transport selection.
|
||||
Covers:
|
||||
|
||||
- Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies,
|
||||
Anthropic-compatible gateways).
|
||||
- ``api.kimi.com/coding`` (Kimi Coding Plan — the /coding route only
|
||||
speaks Claude-Code's native Anthropic shape; ``chat.completions``
|
||||
returns 404 on Anthropic-only model aliases like ``kimi-for-coding``).
|
||||
- ``api.anthropic.com`` (native Anthropic).
|
||||
"""
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
return True
|
||||
if hostname == "api.kimi.com" and "/coding" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _maybe_wrap_anthropic(
|
||||
client_obj: Any,
|
||||
model: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
api_mode: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
|
||||
the endpoint actually speaks Anthropic Messages.
|
||||
|
||||
This is the single chokepoint for aux-client transport correction.
|
||||
Runs at the end of every ``resolve_provider_client`` branch so that
|
||||
api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and
|
||||
future /anthropic gateways all land on the right wire format
|
||||
regardless of which branch built the client.
|
||||
|
||||
Returns ``client_obj`` unchanged when:
|
||||
|
||||
- It's already an Anthropic/Codex/Gemini/CopilotACP wrapper.
|
||||
- The endpoint is an OpenAI-wire endpoint.
|
||||
- ``api_mode`` is explicitly set to a non-Anthropic transport.
|
||||
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
|
||||
"""
|
||||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
if _safe_isinstance(client_obj, GeminiNativeClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if _safe_isinstance(client_obj, CopilotACPClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Explicit non-anthropic api_mode wins over URL heuristics.
|
||||
if api_mode and api_mode != "anthropic_messages":
|
||||
return client_obj
|
||||
|
||||
should_wrap = (
|
||||
api_mode == "anthropic_messages"
|
||||
or _endpoint_speaks_anthropic_messages(base_url)
|
||||
)
|
||||
if not should_wrap:
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
|
||||
"not installed — falling back to OpenAI-wire (will likely 404).",
|
||||
base_url,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
real_client = build_anthropic_client(api_key, base_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to build Anthropic client for %s (%s) — falling back to "
|
||||
"OpenAI-wire client.", base_url, exc,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
logger.debug(
|
||||
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
|
||||
"(model=%s, base_url=%s, api_mode=%s)",
|
||||
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
|
||||
)
|
||||
return AnthropicAuxiliaryClient(
|
||||
real_client, model, api_key, base_url, is_oauth=False,
|
||||
)
|
||||
|
||||
|
||||
def _read_nous_auth() -> Optional[dict]:
|
||||
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
|
||||
@@ -1206,14 +1419,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
# Delegate to the anthropic plugin resolver via the registry
|
||||
from agent.plugin_registries import registries as _ar
|
||||
_anthro_resolver = _ar.get_provider_resolver("anthropic")
|
||||
if _anthro_resolver is not None:
|
||||
_ac, _am = _anthro_resolver()
|
||||
if _ac is not None:
|
||||
return _ac, _am
|
||||
continue
|
||||
return _try_anthropic()
|
||||
|
||||
pool_present, entry = _select_pool_entry(provider_id)
|
||||
if pool_present:
|
||||
@@ -1250,7 +1456,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
except Exception:
|
||||
pass
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider_id)
|
||||
@@ -1287,7 +1493,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
except Exception:
|
||||
pass
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
return None, None
|
||||
@@ -1296,6 +1502,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
# ── Provider resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
pool_present, entry = _select_pool_entry("openrouter")
|
||||
if pool_present:
|
||||
@@ -1620,11 +1827,7 @@ 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.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")
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
real_client = build_anthropic_client(custom_key, custom_base)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
@@ -1639,7 +1842,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
# URL-based anthropic detection for custom endpoints that didn't set
|
||||
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
|
||||
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
_fallback_client = _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
_fallback_client = _maybe_wrap_anthropic(
|
||||
_fallback_client, model, custom_key, custom_base, custom_mode,
|
||||
)
|
||||
return _fallback_client, model
|
||||
@@ -1817,7 +2020,7 @@ def _try_azure_foundry(
|
||||
# for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →
|
||||
# ``build_anthropic_client`` detects the callable and installs
|
||||
# the bearer-injecting httpx hook.
|
||||
return _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
return _maybe_wrap_anthropic(
|
||||
client, final_model, api_key,
|
||||
base_url, runtime_api_mode,
|
||||
), final_model
|
||||
@@ -1826,6 +2029,54 @@ def _try_azure_foundry(
|
||||
return client, final_model
|
||||
|
||||
|
||||
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
|
||||
|
||||
pool_present, entry = _select_pool_entry("anthropic")
|
||||
if pool_present:
|
||||
if entry is None:
|
||||
return None, None
|
||||
token = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
else:
|
||||
entry = None
|
||||
token = explicit_api_key or resolve_anthropic_token()
|
||||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic — otherwise a non-Anthropic
|
||||
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
is_oauth = _is_oauth_token(token)
|
||||
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:
|
||||
real_client = build_anthropic_client(token, base_url)
|
||||
except ImportError:
|
||||
# The anthropic_adapter module imports fine but the SDK itself is
|
||||
# missing — build_anthropic_client raises ImportError at call time
|
||||
# when _anthropic_sdk is None. Treat as unavailable.
|
||||
return None, None
|
||||
return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model
|
||||
|
||||
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
@@ -2406,8 +2657,8 @@ def _retry_same_provider_sync(
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
@@ -2463,8 +2714,8 @@ async def _retry_same_provider_async(
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
@@ -2494,19 +2745,12 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "anthropic":
|
||||
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
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None
|
||||
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None
|
||||
if not str(token or "").strip():
|
||||
if resolve_anthropic_token is not None:
|
||||
token = resolve_anthropic_token()
|
||||
token = resolve_anthropic_token()
|
||||
if not str(token or "").strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
@@ -2845,7 +3089,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
|
||||
if isinstance(sync_client, CodexAuxiliaryClient):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if _safe_isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
@@ -3031,7 +3275,7 @@ def resolve_provider_client(
|
||||
return CodexAuxiliaryClient(client_obj, final_model_str)
|
||||
# Anthropic-wire endpoints: rewrap plain OpenAI clients so
|
||||
# chat.completions.create() is translated to /v1/messages.
|
||||
return _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
return _maybe_wrap_anthropic(
|
||||
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
|
||||
)
|
||||
|
||||
@@ -3263,11 +3507,7 @@ def resolve_provider_client(
|
||||
# branch in _try_custom_endpoint(). See #15033.
|
||||
if entry_api_mode == "anthropic_messages":
|
||||
try:
|
||||
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")
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
real_client = build_anthropic_client(custom_key, custom_base)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
@@ -3310,32 +3550,39 @@ def resolve_provider_client(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Plugin-registered resolvers (azure-foundry, etc.) ──────────────
|
||||
# Providers with complex auth (Entra ID, OAuth, etc.) register a
|
||||
# resolver callable so core doesn't need per-provider if/elif branches.
|
||||
from agent.plugin_registries import registries as _reg_early
|
||||
_early_resolver = _reg_early.get_provider_resolver(provider)
|
||||
if _early_resolver is not None:
|
||||
client, default_model = _early_resolver(
|
||||
# ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─
|
||||
#
|
||||
# The generic PROVIDER_REGISTRY path below uses
|
||||
# ``resolve_api_key_provider_credentials`` which only knows about the
|
||||
# static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important
|
||||
# cases for the ``azure-foundry`` provider:
|
||||
#
|
||||
# 1. ``model.auth_mode: entra_id`` — no static key exists; we need
|
||||
# a callable bearer-token provider from ``azure_identity_adapter``.
|
||||
# 2. Non-default ``model.base_url`` (Foundry projects path) — the
|
||||
# env-var-only resolver doesn't apply config-yaml-driven URL
|
||||
# overrides.
|
||||
#
|
||||
# Delegate to the same runtime resolver the main agent uses so
|
||||
# auxiliary tasks (title generation, compression, vision, embedding,
|
||||
# session search) inherit the user's full Azure config.
|
||||
if provider == "azure-foundry":
|
||||
client, default_model = _try_azure_foundry(
|
||||
model=model,
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
async_mode=async_mode,
|
||||
is_vision=is_vision,
|
||||
main_runtime=main_runtime,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
if client is not None:
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
# Resolver returned None — provider unavailable
|
||||
logger.warning(
|
||||
"resolve_provider_client: %s requested but resolver returned "
|
||||
"no client (run: hermes doctor for diagnostics)",
|
||||
provider,
|
||||
)
|
||||
return None, None
|
||||
if client is None:
|
||||
logger.warning(
|
||||
"resolve_provider_client: azure-foundry requested but "
|
||||
"runtime resolution failed (run: hermes doctor for "
|
||||
"diagnostics)"
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
||||
try:
|
||||
@@ -3354,6 +3601,14 @@ def resolve_provider_client(
|
||||
return None, None
|
||||
|
||||
if pconfig.auth_type == "api_key":
|
||||
if provider == "anthropic":
|
||||
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
|
||||
if client is None:
|
||||
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model))
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider)
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
# Honour an explicit api_key override (e.g. from a fallback_model entry
|
||||
@@ -3486,14 +3741,37 @@ def resolve_provider_client(
|
||||
return None, None
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (e.g. Bedrock) — handled by the early resolver
|
||||
# catch above when a plugin registers one. If we reach here, no
|
||||
# resolver was registered.
|
||||
logger.warning(
|
||||
"resolve_provider_client: aws_sdk provider %s has no "
|
||||
"registered resolver (plugin not loaded?)", provider,
|
||||
# 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
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
"boto3 or anthropic SDK not installed")
|
||||
return None, None
|
||||
|
||||
if not has_aws_credentials():
|
||||
logger.debug("resolve_provider_client: bedrock requested but "
|
||||
"no AWS credentials found")
|
||||
return None, None
|
||||
|
||||
region = resolve_bedrock_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
return None, None
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
|
||||
# OAuth providers — route through their specific try functions
|
||||
@@ -3617,12 +3895,7 @@ def _resolve_strict_vision_backend(
|
||||
# allow-list); callers must specify via auxiliary.<task>.model.
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
from agent.plugin_registries import registries as _reg
|
||||
_resolver = _reg.get_provider_resolver("anthropic")
|
||||
if _resolver is not None:
|
||||
return _resolver(model=model)
|
||||
# Fallback: no resolver registered (plugin not loaded)
|
||||
return None, None
|
||||
return _try_anthropic()
|
||||
if provider == "custom":
|
||||
return _try_custom_endpoint()
|
||||
return None, None
|
||||
@@ -4352,6 +4625,69 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
|
||||
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
|
||||
# Their image content blocks must use Anthropic format, not OpenAI format.
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
|
||||
|
||||
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
"""Detect if an endpoint expects Anthropic-format content blocks.
|
||||
|
||||
Returns True for known Anthropic-compatible providers (MiniMax) and
|
||||
any endpoint whose URL contains ``/anthropic`` in the path.
|
||||
"""
|
||||
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
|
||||
return True
|
||||
url_lower = (base_url or "").lower()
|
||||
return "/anthropic" in url_lower
|
||||
|
||||
|
||||
def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
|
||||
|
||||
Only touches messages that have list-type content with ``image_url`` blocks;
|
||||
plain text messages pass through unchanged.
|
||||
"""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
converted.append(msg)
|
||||
continue
|
||||
new_content = []
|
||||
changed = False
|
||||
for block in content:
|
||||
if block.get("type") == "image_url":
|
||||
image_url_val = (block.get("image_url") or {}).get("url", "")
|
||||
if image_url_val.startswith("data:"):
|
||||
# Parse data URI: data:<media_type>;base64,<data>
|
||||
header, _, b64data = image_url_val.partition(",")
|
||||
media_type = "image/png"
|
||||
if ":" in header and ";" in header:
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# URL-based image
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": image_url_val,
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
new_content.append(block)
|
||||
converted.append({**msg, "content": new_content} if changed else msg)
|
||||
return converted
|
||||
|
||||
|
||||
|
||||
def _build_call_kwargs(
|
||||
provider: str,
|
||||
model: str,
|
||||
@@ -4381,10 +4717,8 @@ 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.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):
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if _forbids_sampling_params(model):
|
||||
temperature = None
|
||||
|
||||
if temperature is not None:
|
||||
@@ -4596,8 +4930,8 @@ def call_llm(
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
|
||||
|
||||
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
|
||||
# then payment fallback.
|
||||
@@ -5039,8 +5373,8 @@ async def async_call_llm(
|
||||
base_url=_client_base or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
|
||||
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
|
||||
+24
-7
@@ -54,6 +54,8 @@ 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.
|
||||
@@ -68,20 +70,35 @@ def has_azure_identity_installed() -> bool:
|
||||
|
||||
|
||||
def _require_azure_identity():
|
||||
"""Import ``azure.identity``.
|
||||
"""Import ``azure.identity``, lazy-installing it if allowed.
|
||||
|
||||
Raises ``ImportError`` with a clear actionable message when the
|
||||
package is missing.
|
||||
package is missing and lazy installs are disabled.
|
||||
"""
|
||||
try:
|
||||
import azure.identity as _ai
|
||||
return _ai
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'azure-identity' package is required for Azure AI "
|
||||
"Foundry Entra ID authentication. Install it with: "
|
||||
"pip install azure-identity"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def reset_credential_cache() -> None:
|
||||
+13
@@ -36,6 +36,19 @@ 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.
|
||||
@@ -202,14 +202,12 @@ 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.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")
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
normalize_converse_response,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
@@ -683,11 +681,8 @@ 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.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)
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
_ant_max = _get_anthropic_max_output(agent.model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1172,20 +1167,15 @@ 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.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 "")
|
||||
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 "")
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = fb_base_url
|
||||
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._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
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
@@ -1569,14 +1559,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
|
||||
def _bedrock_call():
|
||||
try:
|
||||
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")
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
|
||||
@@ -27,8 +27,6 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
@@ -2372,8 +2370,8 @@ def run_conversation(
|
||||
and not anthropic_auth_retry_attempted
|
||||
):
|
||||
anthropic_auth_retry_attempted = True
|
||||
_is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token")
|
||||
is_token_provider = _registries.get_provider_service("azure", "is_token_provider")
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if agent._try_refresh_anthropic_client_credentials():
|
||||
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||||
continue
|
||||
@@ -2390,7 +2388,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 is not None and _is_oauth_token(key)) else "x-api-key (API key)"
|
||||
auth_method = "Bearer (OAuth/setup-token)" if _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:")
|
||||
|
||||
+196
-49
@@ -537,6 +537,43 @@ class CredentialPool:
|
||||
self._persist()
|
||||
return updated
|
||||
|
||||
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
|
||||
|
||||
OAuth refresh tokens are single-use. When something external (e.g.
|
||||
Claude Code CLI, or another profile's pool) refreshes the token, it
|
||||
writes the new pair to ~/.claude/.credentials.json. The pool entry's
|
||||
refresh token becomes stale. This method detects that and syncs.
|
||||
"""
|
||||
if self.provider != "anthropic" or entry.source != "claude_code":
|
||||
return entry
|
||||
try:
|
||||
from agent.anthropic_adapter import read_claude_code_credentials
|
||||
creds = read_claude_code_credentials()
|
||||
if not creds:
|
||||
return entry
|
||||
file_refresh = creds.get("refreshToken", "")
|
||||
file_access = creds.get("accessToken", "")
|
||||
file_expires = creds.get("expiresAt", 0)
|
||||
# If the credentials file has a different token pair, sync it
|
||||
if file_refresh and file_refresh != entry.refresh_token:
|
||||
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=file_access,
|
||||
refresh_token=file_refresh,
|
||||
expires_at_ms=file_expires,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
return updated
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync from credentials file: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_codex_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a Codex device_code pool entry from auth.json if tokens differ.
|
||||
|
||||
@@ -826,11 +863,32 @@ class CredentialPool:
|
||||
return None
|
||||
|
||||
try:
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cph_reg2
|
||||
_hook = _cph_reg2.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.refresh_oauth is not None:
|
||||
updated = _hook.refresh_oauth(entry, pool=self)
|
||||
if self.provider == "anthropic":
|
||||
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
|
||||
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
entry.refresh_token,
|
||||
use_json=entry.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
)
|
||||
# Keep ~/.claude/.credentials.json in sync so that the
|
||||
# fallback path (resolve_anthropic_token) and other profiles
|
||||
# see the latest tokens.
|
||||
if entry.source == "claude_code":
|
||||
try:
|
||||
from agent.anthropic_adapter import _write_claude_code_credentials
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||
elif self.provider == "openai-codex":
|
||||
# Adopt fresher tokens from auth.json before spending the
|
||||
# refresh_token — single-use tokens consumed by another Hermes
|
||||
@@ -880,18 +938,46 @@ class CredentialPool:
|
||||
return entry
|
||||
except Exception as exc:
|
||||
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
# The hook's refresh_oauth already handles retry-with-sync internally,
|
||||
# so if we got here it means a non-hook provider failed.
|
||||
from agent.plugin_registries import registries as _cph_reg3
|
||||
_hook = _cph_reg3.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.sync_from_credentials_file is not None:
|
||||
# Give the hook a chance to sync from external file
|
||||
synced = _hook.sync_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
self._replace_entry(entry, synced)
|
||||
self._persist()
|
||||
# For anthropic claude_code entries: the refresh token may have been
|
||||
# consumed by another process. Check if ~/.claude/.credentials.json
|
||||
# has a newer token pair and retry once.
|
||||
if self.provider == "anthropic" and entry.source == "claude_code":
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
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
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
synced.refresh_token,
|
||||
use_json=synced.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
synced,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
last_status=STATUS_OK,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
self._replace_entry(synced, updated)
|
||||
self._persist()
|
||||
try:
|
||||
from agent.anthropic_adapter import _write_claude_code_credentials
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file (retry path): %s", wexc)
|
||||
return updated
|
||||
except Exception as retry_exc:
|
||||
logger.debug("Retry refresh also failed: %s", retry_exc)
|
||||
elif not self._entry_needs_refresh(synced):
|
||||
# Credentials file had a valid (non-expired) token — use it directly
|
||||
logger.debug("Credentials file has valid token, using without refresh")
|
||||
return synced
|
||||
# For xai-oauth: same race as nous — another process may have
|
||||
# consumed the refresh token between our proactive sync and the
|
||||
# HTTP call. Re-check auth.json and adopt the fresh tokens if
|
||||
@@ -1112,11 +1198,10 @@ class CredentialPool:
|
||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||
if entry.auth_type != AUTH_TYPE_OAUTH:
|
||||
return False
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cph_reg
|
||||
_hook = _cph_reg.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.needs_refresh is not None:
|
||||
return _hook.needs_refresh(entry)
|
||||
if self.provider == "anthropic":
|
||||
if entry.expires_at_ms is None:
|
||||
return False
|
||||
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
|
||||
if self.provider == "openai-codex":
|
||||
return _codex_access_token_is_expiring(
|
||||
entry.access_token,
|
||||
@@ -1150,16 +1235,12 @@ class CredentialPool:
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
# Sync exhausted entries from external credentials files before
|
||||
# status/refresh checks. This picks up tokens refreshed by other
|
||||
# processes (e.g. Claude Code CLI, other Hermes profiles).
|
||||
from agent.plugin_registries import registries as _cph_reg4
|
||||
_avail_hook = _cph_reg4.get_credential_pool_hook(self.provider)
|
||||
if (_avail_hook is not None
|
||||
and _avail_hook.sync_from_credentials_file is not None
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
if (self.provider == "anthropic" and entry.source == "claude_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = _avail_hook.sync_from_credentials_file(entry)
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
@@ -1553,15 +1634,84 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
def _is_suppressed(_p, _s): # type: ignore[misc]
|
||||
return False
|
||||
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cp_reg
|
||||
_cp_hook = _cp_reg.get_credential_pool_hook(provider)
|
||||
if _cp_hook is not None and _cp_hook.discover_credentials is not None:
|
||||
hook_changed, hook_sources = _cp_hook.discover_credentials(
|
||||
entries, provider, _is_suppressed,
|
||||
if provider == "anthropic":
|
||||
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
|
||||
# when the user has explicitly configured anthropic as their provider.
|
||||
# Without this gate, auxiliary client fallback chains silently read
|
||||
# ~/.claude/.credentials.json without user consent. See PR #4210.
|
||||
try:
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
if not is_provider_explicitly_configured("anthropic"):
|
||||
return changed, active_sources
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# API-key vs OAuth is a user-visible choice at `hermes setup` ("Claude
|
||||
# Pro/Max subscription" vs "Anthropic API key"). The signal that the
|
||||
# user picked the API-key path is: ANTHROPIC_API_KEY set in the env,
|
||||
# AND no OAuth env vars set — `save_anthropic_api_key()` writes the
|
||||
# API key and zeros ANTHROPIC_TOKEN; `save_anthropic_oauth_token()`
|
||||
# does the inverse. When that signal is present we MUST NOT seed
|
||||
# autodiscovered OAuth tokens (~/.claude/.credentials.json from the
|
||||
# Claude Code CLI, hermes_pkce creds from a previous OAuth login)
|
||||
# into the anthropic pool — otherwise rotation on a 401/429 silently
|
||||
# flips the session onto an OAuth credential, which forces the Claude
|
||||
# Code identity injection, `mcp_` tool-name rewrite, and claude-cli
|
||||
# User-Agent header (`agent/anthropic_adapter.py:2128`). Users who
|
||||
# explicitly opted into the API-key path are explicitly opting OUT of
|
||||
# that masquerade. Prefer ~/.hermes/.env over os.environ for the
|
||||
# same reason `_seed_from_env` does — that's the authoritative file
|
||||
# that `hermes setup` writes.
|
||||
_env_file = load_env()
|
||||
|
||||
def _env_val(key: str) -> str:
|
||||
return (_env_file.get(key) or os.environ.get(key) or "").strip()
|
||||
|
||||
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
|
||||
anthropic_oauth_env = (
|
||||
_env_val("ANTHROPIC_TOKEN") or _env_val("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
)
|
||||
changed |= hook_changed
|
||||
active_sources |= hook_sources
|
||||
api_key_path_explicit = bool(anthropic_api_key and not anthropic_oauth_env)
|
||||
|
||||
if api_key_path_explicit:
|
||||
# Prune any stale autodiscovered OAuth entries that may have been
|
||||
# seeded into the on-disk pool during a previous OAuth session.
|
||||
# Without this, switching OAuth -> API key at setup leaves the
|
||||
# OAuth entries dormant in auth.json forever and rotation on a
|
||||
# transient 401 could revive them.
|
||||
retained = [
|
||||
entry for entry in entries
|
||||
if entry.source not in {"hermes_pkce", "claude_code"}
|
||||
]
|
||||
if len(retained) != len(entries):
|
||||
entries[:] = retained
|
||||
changed = True
|
||||
return changed, active_sources
|
||||
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
|
||||
|
||||
for source_name, creds in (
|
||||
("hermes_pkce", read_hermes_oauth_credentials()),
|
||||
("claude_code", read_claude_code_credentials()),
|
||||
):
|
||||
if creds and creds.get("accessToken"):
|
||||
if _is_suppressed(provider, source_name):
|
||||
continue
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": creds.get("accessToken", ""),
|
||||
"refresh_token": creds.get("refreshToken"),
|
||||
"expires_at_ms": creds.get("expiresAt"),
|
||||
"label": label_from_token(creds.get("accessToken", ""), source_name),
|
||||
},
|
||||
)
|
||||
|
||||
elif provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
has_runtime_material = bool(
|
||||
@@ -1872,11 +2022,12 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
|
||||
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
# ── Plugin-registered credential pool hooks: env var order override ──
|
||||
from agent.plugin_registries import registries as _env_reg
|
||||
_env_hook = _env_reg.get_credential_pool_hook(provider)
|
||||
if _env_hook is not None and _env_hook.env_var_order is not None:
|
||||
env_vars = _env_hook.env_var_order
|
||||
if provider == "anthropic":
|
||||
env_vars = [
|
||||
"ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]
|
||||
|
||||
for env_var in env_vars:
|
||||
# Prefer ~/.hermes/.env over os.environ
|
||||
@@ -1887,11 +2038,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
# ── Plugin-registered credential pool hooks: auth type detection ──
|
||||
if _env_hook is not None and _env_hook.detect_auth_type is not None:
|
||||
auth_type = _env_hook.detect_auth_type(token)
|
||||
else:
|
||||
auth_type = AUTH_TYPE_API_KEY
|
||||
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
|
||||
base_url = env_url or pconfig.inference_base_url
|
||||
if provider == "kimi-coding":
|
||||
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
|
||||
|
||||
@@ -1567,11 +1567,8 @@ def get_model_context_length(
|
||||
and base_url_host_matches(base_url, "amazonaws.com")
|
||||
):
|
||||
try:
|
||||
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)
|
||||
from agent.bedrock_adapter import get_bedrock_context_length
|
||||
return get_bedrock_context_length(model)
|
||||
except ImportError:
|
||||
pass # boto3 not installed — fall through to generic resolution
|
||||
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
"""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,
|
||||
Tuple,
|
||||
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."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider resolvers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class ProviderResolver(Protocol):
|
||||
"""A plugin that resolves an auxiliary client for a specific provider.
|
||||
|
||||
Registered via ``ctx.register_provider_resolver(provider_name, resolver)``.
|
||||
Queried by ``agent/auxiliary_client.py`` in ``resolve_provider_client()``.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
explicit_api_key: str | None = None,
|
||||
explicit_base_url: str | None = None,
|
||||
async_mode: bool = False,
|
||||
is_vision: bool = False,
|
||||
main_runtime: dict | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
"""Return ``(client, default_model)`` or ``(None, None)`` if unavailable."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential pool hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CredentialPoolHook:
|
||||
"""Provider-specific credential pool operations.
|
||||
|
||||
Registered via ``ctx.register_credential_pool_hook(provider_name, hook)``.
|
||||
Queried by ``agent/credential_pool.py``.
|
||||
"""
|
||||
|
||||
sync_from_credentials_file: Optional[Callable] = None
|
||||
"""Sync a pool entry from an external credentials file (e.g. ~/.claude/.credentials.json)."""
|
||||
|
||||
refresh_oauth: Optional[Callable] = None
|
||||
"""Refresh an OAuth token for a pool entry."""
|
||||
|
||||
should_include_in_pool: Optional[Callable] = None
|
||||
"""Return True if this provider's credentials should be included in the pool."""
|
||||
|
||||
needs_refresh: Optional[Callable] = None
|
||||
"""Return True if an OAuth entry needs a token refresh."""
|
||||
|
||||
source_priority: Optional[Callable] = None
|
||||
"""Return integer priority for a credential source (lower = preferred)."""
|
||||
|
||||
discover_credentials: Optional[Callable] = None
|
||||
"""Discover external credentials and upsert into the pool entries.
|
||||
|
||||
Signature: (entries: list, provider: str, is_suppressed: Callable) -> (changed: bool, active_sources: set)
|
||||
"""
|
||||
|
||||
env_var_order: Optional[list] = None
|
||||
"""Override env var scan order for this provider (e.g. ['ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'])."""
|
||||
|
||||
detect_auth_type: Optional[Callable] = None
|
||||
"""Given a token string, return the auth type for this provider.
|
||||
|
||||
Signature: (token: str) -> str (e.g. AUTH_TYPE_OAUTH or AUTH_TYPE_API_KEY)
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pricing providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Re-export PricingEntry from usage_pricing — that's the canonical definition
|
||||
# with Decimal fields. The registry stores these directly keyed by (provider, model).
|
||||
# Lazy import to avoid circular dependency (usage_pricing imports registries at runtime).
|
||||
def _get_pricing_entry_class():
|
||||
from agent.usage_pricing import PricingEntry
|
||||
return PricingEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProviderOverlayEntry:
|
||||
"""A provider overlay registered by a plugin.
|
||||
|
||||
Registered via ``ctx.register_provider_overlay(provider_name, entry)``.
|
||||
Queried by ``hermes_cli/providers.py``.
|
||||
|
||||
This mirrors the fields of ``HermesOverlay`` so that providers.py
|
||||
can merge plugin-registered overlays seamlessly.
|
||||
"""
|
||||
|
||||
provider_name: str
|
||||
"""Primary provider name (e.g. 'anthropic', 'bedrock')."""
|
||||
|
||||
transport: str = "openai_chat"
|
||||
"""Transport type: openai_chat | anthropic_messages | codex_responses | bedrock_converse"""
|
||||
|
||||
is_aggregator: bool = False
|
||||
"""Whether this provider aggregates multiple model providers."""
|
||||
|
||||
auth_type: str = "api_key"
|
||||
"""Auth type: api_key | oauth_device_code | oauth_external | aws_sdk | external_process"""
|
||||
|
||||
extra_env_vars: Tuple[str, ...] = ()
|
||||
"""Environment variable names that indicate this provider is configured."""
|
||||
|
||||
base_url_override: str = ""
|
||||
"""Override if models.dev URL is wrong/missing."""
|
||||
|
||||
base_url_env_var: str = ""
|
||||
"""Env var for user-custom base URL."""
|
||||
|
||||
display_name: str = ""
|
||||
"""Human-readable name for the provider (e.g. 'Anthropic', 'AWS Bedrock')."""
|
||||
|
||||
aliases: List[str] = field(default_factory=list)
|
||||
"""Alternative names that resolve to this provider."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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._transports: Dict[str, type] = {}
|
||||
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]] = {}
|
||||
self._provider_resolvers: Dict[str, Callable] = {}
|
||||
self._credential_pool_hooks: Dict[str, CredentialPoolHook] = {}
|
||||
self._pricing_providers: Dict[tuple, Any] = {}
|
||||
self._provider_overlays: Dict[str, ProviderOverlayEntry] = {}
|
||||
|
||||
# -- 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
|
||||
|
||||
def register_provider_resolver(self, name: str, resolver: Callable) -> None:
|
||||
"""Register a provider resolver callable.
|
||||
|
||||
The resolver is called by ``resolve_provider_client()`` to create an
|
||||
auxiliary client for a specific provider. Signature::
|
||||
|
||||
def resolver(
|
||||
*,
|
||||
model: str | None,
|
||||
explicit_api_key: str | None,
|
||||
explicit_base_url: str | None,
|
||||
async_mode: bool,
|
||||
is_vision: bool,
|
||||
main_runtime: dict | None,
|
||||
api_mode: str | None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
...
|
||||
|
||||
Returns ``(client, default_model)`` or ``(None, None)``.
|
||||
"""
|
||||
self._provider_resolvers[name] = resolver
|
||||
|
||||
def register_credential_pool_hook(self, name: str, hook: CredentialPoolHook) -> None:
|
||||
"""Register a credential pool hook for provider-specific pool operations."""
|
||||
self._credential_pool_hooks[name] = hook
|
||||
|
||||
def register_pricing_provider(self, name: str, entries: List[tuple]) -> None:
|
||||
"""Register pricing entries for a provider.
|
||||
|
||||
Each entry is a (provider, model, PricingEntry) tuple so the
|
||||
lookup key matches the (provider, model) pattern used by
|
||||
_OFFICIAL_DOCS_PRICING.
|
||||
"""
|
||||
for prov, model, entry in entries:
|
||||
self._pricing_providers[(prov, model)] = entry
|
||||
|
||||
def register_provider_overlay(self, entry: ProviderOverlayEntry) -> None:
|
||||
"""Register a provider overlay entry from a plugin."""
|
||||
self._provider_overlays[entry.provider_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 get_provider_resolver(self, name: str) -> Callable | None:
|
||||
"""Return the registered resolver for a provider, or None."""
|
||||
return self._provider_resolvers.get(name)
|
||||
|
||||
def get_credential_pool_hook(self, name: str) -> CredentialPoolHook | None:
|
||||
"""Return the registered credential pool hook for a provider, or None."""
|
||||
return self._credential_pool_hooks.get(name)
|
||||
|
||||
def get_pricing_entry(self, provider: str, model: str) -> Any:
|
||||
"""Return a registered pricing entry for (provider, model), or None."""
|
||||
return self._pricing_providers.get((provider, model))
|
||||
|
||||
def all_pricing_entries(self) -> Dict[tuple, Any]:
|
||||
"""Return all registered pricing entries (keyed by (provider, model))."""
|
||||
return dict(self._pricing_providers)
|
||||
|
||||
def get_provider_overlay(self, name: str) -> ProviderOverlayEntry | None:
|
||||
"""Return a registered provider overlay, or None."""
|
||||
return self._provider_overlays.get(name)
|
||||
|
||||
def all_provider_overlays(self) -> Dict[str, ProviderOverlayEntry]:
|
||||
"""Return all registered provider overlays."""
|
||||
return dict(self._provider_overlays)
|
||||
|
||||
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 _resolve(self) -> Any:
|
||||
mod = sys.modules.get(self._mod)
|
||||
return getattr(mod, self._attr, self._fallback) if mod else self._fallback
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return self._resolve()(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
return getattr(self._resolve(), name)
|
||||
|
||||
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
-1
@@ -331,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
|
||||
@@ -47,16 +47,9 @@ def get_transport(api_mode: str):
|
||||
|
||||
|
||||
def _discover_transports() -> None:
|
||||
"""Import all transport modules to trigger auto-registration.
|
||||
|
||||
Also checks the plugin registry for transports registered by plugins
|
||||
(e.g. anthropic_messages from the anthropic plugin, bedrock_converse
|
||||
from the bedrock plugin). Plugin-registered transports take priority
|
||||
over core fallbacks when both exist.
|
||||
"""
|
||||
"""Import all transport modules to trigger auto-registration."""
|
||||
global _discovered
|
||||
_discovered = True
|
||||
# Core transport modules (registered automatically — no plugin needed)
|
||||
try:
|
||||
import agent.transports.anthropic # noqa: F401
|
||||
except ImportError:
|
||||
@@ -69,10 +62,7 @@ def _discover_transports() -> None:
|
||||
import agent.transports.chat_completions # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
# Plugin-registered transports (override core fallbacks)
|
||||
try:
|
||||
from agent.plugin_registries import registries
|
||||
for api_mode, transport_cls in registries._transports.items():
|
||||
_REGISTRY.setdefault(api_mode, transport_cls)
|
||||
import agent.transports.bedrock # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1,53 +1,41 @@
|
||||
"""Anthropic Messages API transport — core module.
|
||||
"""Anthropic Messages API transport.
|
||||
|
||||
Owns format conversion and response normalization for the ``anthropic_messages``
|
||||
wire format. No SDK dependency; all wire-format logic lives in
|
||||
:mod:`agent.anthropic_format`.
|
||||
Delegates to the existing adapter functions in agent/anthropic_adapter.py.
|
||||
This transport owns format conversion and normalization — NOT client lifecycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.anthropic_format import (
|
||||
build_anthropic_kwargs,
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
_to_plain_data,
|
||||
)
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
from agent.transports.types import NormalizedResponse
|
||||
|
||||
|
||||
class AnthropicTransport(ProviderTransport):
|
||||
"""Transport for api_mode='anthropic_messages'.
|
||||
|
||||
Uses core functions directly from :mod:`agent.anthropic_format` — no
|
||||
plugin registry lookups needed. This means core tests, bedrock tests,
|
||||
and any other consumer of the anthropic wire format work without the
|
||||
anthropic plugin being registered.
|
||||
Wraps the existing functions in anthropic_adapter.py behind the
|
||||
ProviderTransport ABC. Each method delegates — no logic is duplicated.
|
||||
"""
|
||||
|
||||
_STOP_REASON_MAP = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"refusal": "content_filter",
|
||||
"model_context_window_exceeded": "length",
|
||||
}
|
||||
|
||||
@property
|
||||
def api_mode(self) -> str:
|
||||
return "anthropic_messages"
|
||||
|
||||
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
|
||||
"""Convert OpenAI messages to Anthropic (system, messages) tuple."""
|
||||
"""Convert OpenAI messages to Anthropic (system, messages) tuple.
|
||||
|
||||
kwargs:
|
||||
base_url: Optional[str] — affects thinking signature handling.
|
||||
"""
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
base_url = kwargs.get("base_url")
|
||||
return convert_messages_to_anthropic(messages, base_url=base_url,
|
||||
model=kwargs.get("model"))
|
||||
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
|
||||
|
||||
return convert_tools_to_anthropic(tools)
|
||||
|
||||
def build_kwargs(
|
||||
@@ -57,7 +45,23 @@ class AnthropicTransport(ProviderTransport):
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
**params,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build Anthropic messages.create() kwargs."""
|
||||
"""Build Anthropic messages.create() kwargs.
|
||||
|
||||
Calls convert_messages and convert_tools internally.
|
||||
|
||||
params (all optional):
|
||||
max_tokens: int
|
||||
reasoning_config: dict | None
|
||||
tool_choice: str | None
|
||||
is_oauth: bool
|
||||
preserve_dots: bool
|
||||
context_length: int | None
|
||||
base_url: str | None
|
||||
fast_mode: bool
|
||||
drop_context_1m_beta: bool
|
||||
"""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
return build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -74,7 +78,15 @@ class AnthropicTransport(ProviderTransport):
|
||||
)
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
"""Normalize Anthropic response to NormalizedResponse."""
|
||||
"""Normalize Anthropic response to NormalizedResponse.
|
||||
|
||||
Parses content blocks (text, thinking, tool_use), maps stop_reason
|
||||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||
"""
|
||||
import json
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
@@ -95,6 +107,12 @@ class AnthropicTransport(ProviderTransport):
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
stripped = name[len(_MCP_PREFIX):]
|
||||
# Only strip the mcp_ prefix for OAuth-injected tools
|
||||
# (where Hermes adds the prefix when sending to Anthropic
|
||||
# and must remove it on the way back). Native MCP server
|
||||
# tools (from mcp_servers: in config.yaml) are registered
|
||||
# in the tool registry under their FULL mcp_<server>_<tool>
|
||||
# name and must NOT be stripped. GH-25255.
|
||||
from tools.registry import registry as _tool_registry
|
||||
if (_tool_registry.get_entry(stripped)
|
||||
and not _tool_registry.get_entry(name)):
|
||||
@@ -123,7 +141,13 @@ class AnthropicTransport(ProviderTransport):
|
||||
)
|
||||
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid."""
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
content_blocks = getattr(response, "content", None)
|
||||
@@ -144,6 +168,16 @@ class AnthropicTransport(ProviderTransport):
|
||||
return {"cached_tokens": cached, "creation_tokens": written}
|
||||
return None
|
||||
|
||||
# Promote the adapter's canonical mapping to module level so it's shared
|
||||
_STOP_REASON_MAP = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"refusal": "content_filter",
|
||||
"model_context_window_exceeded": "length",
|
||||
}
|
||||
|
||||
def map_finish_reason(self, raw_reason: str) -> str:
|
||||
"""Map Anthropic stop_reason to OpenAI finish_reason."""
|
||||
return self._STOP_REASON_MAP.get(raw_reason, "stop")
|
||||
|
||||
+45
-22
@@ -1,6 +1,6 @@
|
||||
"""AWS Bedrock Converse API transport.
|
||||
|
||||
Delegates to the existing adapter functions in hermes_agent_bedrock.
|
||||
Delegates to the existing adapter functions in agent/bedrock_adapter.py.
|
||||
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,19 +21,13 @@ class BedrockTransport(ProviderTransport):
|
||||
|
||||
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
|
||||
"""Convert OpenAI messages to Bedrock Converse format."""
|
||||
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)
|
||||
from agent.bedrock_adapter import convert_messages_to_converse
|
||||
return convert_messages_to_converse(messages)
|
||||
|
||||
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
|
||||
"""Convert OpenAI tool schemas to Bedrock Converse toolConfig."""
|
||||
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)
|
||||
from agent.bedrock_adapter import convert_tools_to_converse
|
||||
return convert_tools_to_converse(tools)
|
||||
|
||||
def build_kwargs(
|
||||
self,
|
||||
@@ -42,16 +36,22 @@ class BedrockTransport(ProviderTransport):
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
**params,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build Bedrock 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")
|
||||
"""Build Bedrock converse() kwargs.
|
||||
|
||||
Calls convert_messages and convert_tools internally.
|
||||
|
||||
params:
|
||||
max_tokens: int — output token limit (default 4096)
|
||||
temperature: float | None
|
||||
guardrail_config: dict | None — Bedrock guardrails
|
||||
region: str — AWS region (default 'us-east-1')
|
||||
"""
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
|
||||
region = params.get("region", "us-east-1")
|
||||
guardrail = params.get("guardrail_config")
|
||||
|
||||
kwargs = _fn(
|
||||
kwargs = build_converse_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
@@ -65,15 +65,20 @@ class BedrockTransport(ProviderTransport):
|
||||
return kwargs
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
"""Normalize Bedrock response to NormalizedResponse."""
|
||||
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 Bedrock response to NormalizedResponse.
|
||||
|
||||
Handles two shapes:
|
||||
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
|
||||
|
||||
# Normalize to OpenAI-compatible SimpleNamespace
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
# Already normalized at dispatch site
|
||||
ns = response
|
||||
else:
|
||||
# Raw boto3 dict
|
||||
ns = normalize_converse_response(response)
|
||||
|
||||
choice = ns.choices[0]
|
||||
@@ -111,15 +116,27 @@ class BedrockTransport(ProviderTransport):
|
||||
)
|
||||
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Bedrock response structure.
|
||||
|
||||
After normalize_converse_response, the response has OpenAI-compatible
|
||||
.choices — same check as chat_completions.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
# Raw Bedrock dict response — check for 'output' key
|
||||
if isinstance(response, dict):
|
||||
return "output" in response
|
||||
# Already-normalized SimpleNamespace
|
||||
if hasattr(response, "choices"):
|
||||
return bool(response.choices)
|
||||
return False
|
||||
|
||||
def map_finish_reason(self, raw_reason: str) -> str:
|
||||
"""Map Bedrock stop reason to OpenAI finish_reason.
|
||||
|
||||
The adapter already does this mapping inside normalize_converse_response,
|
||||
so this is only used for direct access to raw responses.
|
||||
"""
|
||||
_MAP = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
@@ -129,3 +146,9 @@ class BedrockTransport(ProviderTransport):
|
||||
"content_filtered": "content_filter",
|
||||
}
|
||||
return _MAP.get(raw_reason, "stop")
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
from agent.transports import register_transport # noqa: E402
|
||||
|
||||
register_transport("bedrock_converse", BedrockTransport)
|
||||
+152
-68
@@ -115,8 +115,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
|
||||
# tokens for the same text).
|
||||
# Source: https://platform.claude.com/docs/en/about-claude/pricing
|
||||
# NOTE: The anthropic plugin also registers these — plugin takes priority
|
||||
# at runtime, but these static entries ensure costs work without the plugin.
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-7",
|
||||
@@ -141,6 +139,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
@@ -189,6 +188,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-5",
|
||||
@@ -225,6 +225,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-20250514",
|
||||
@@ -249,56 +250,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-haiku-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-opus-20240229",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-haiku-20240307",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.25"),
|
||||
output_cost_per_million=Decimal("1.25"),
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
cache_write_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── OpenAI ────────────────────────────────────────────────────────────
|
||||
# OpenAI
|
||||
(
|
||||
"openai",
|
||||
"gpt-4o",
|
||||
@@ -376,6 +328,55 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://openai.com/api/pricing/",
|
||||
pricing_version="openai-pricing-2026-03-16",
|
||||
),
|
||||
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-haiku-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-opus-20240229",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-haiku-20240307",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.25"),
|
||||
output_cost_per_million=Decimal("1.25"),
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
cache_write_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# DeepSeek
|
||||
(
|
||||
"deepseek",
|
||||
@@ -439,6 +440,80 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
),
|
||||
# AWS Bedrock — pricing per the Bedrock pricing page.
|
||||
# Bedrock charges the same per-token rates as the model provider but
|
||||
# through AWS billing. These are the on-demand prices (no commitment).
|
||||
# Source: https://aws.amazon.com/bedrock/pricing/
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-opus-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-haiku-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("3.20"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-lite",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.06"),
|
||||
output_cost_per_million=Decimal("0.24"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-micro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.035"),
|
||||
output_cost_per_million=Decimal("0.14"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
# MiniMax
|
||||
(
|
||||
"minimax",
|
||||
@@ -506,27 +581,36 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
||||
|
||||
|
||||
def _normalize_anthropic_model_name(model: str) -> str:
|
||||
"""Normalize Anthropic model name variants to canonical form.
|
||||
|
||||
Handles:
|
||||
- Dot notation: claude-opus-4.7 → claude-opus-4-7
|
||||
- Short aliases: claude-opus-4.7 → claude-opus-4-7
|
||||
- Strips anthropic/ prefix if present
|
||||
"""
|
||||
name = model.lower().strip()
|
||||
if name.startswith("anthropic/"):
|
||||
name = name[len("anthropic/"):]
|
||||
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
|
||||
# But preserve the rest of the name structure
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
return name
|
||||
|
||||
|
||||
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
model = route.model.lower()
|
||||
|
||||
# ── Plugin-registered pricing entries take priority ──
|
||||
from agent.plugin_registries import registries as _preg
|
||||
plugin_entry = _preg.get_pricing_entry(route.provider, model)
|
||||
if plugin_entry:
|
||||
return plugin_entry
|
||||
# Try provider-specific name normalization via registry
|
||||
_norm = _preg.get_provider_service(route.provider, "normalize_model_name")
|
||||
if _norm is not None:
|
||||
normalized = _norm(model)
|
||||
if normalized != model:
|
||||
plugin_entry = _preg.get_pricing_entry(route.provider, normalized)
|
||||
if plugin_entry:
|
||||
return plugin_entry
|
||||
|
||||
# Fall back to static dict
|
||||
# Direct lookup first
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
|
||||
if entry:
|
||||
return entry
|
||||
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
|
||||
if route.provider == "anthropic":
|
||||
normalized = _normalize_anthropic_model_name(model)
|
||||
if normalized != model:
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
||||
if entry:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ model:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Working directory behavior:
|
||||
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
|
||||
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
|
||||
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
|
||||
terminal:
|
||||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
|
||||
@@ -6252,10 +6252,8 @@ 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.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):
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if 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:]}"
|
||||
@@ -10986,14 +10984,7 @@ class HermesCLI:
|
||||
return
|
||||
self._voice_tts_done.clear()
|
||||
try:
|
||||
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.tts_tool import text_to_speech_tool
|
||||
from tools.voice_mode import play_audio_file
|
||||
|
||||
# Strip markdown and non-speech content for cleaner TTS
|
||||
@@ -11176,10 +11167,8 @@ class HermesCLI:
|
||||
status = "enabled" if self._voice_tts else "disabled"
|
||||
|
||||
if self._voice_tts:
|
||||
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():
|
||||
from tools.tts_tool import check_tts_requirements
|
||||
if 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}")
|
||||
@@ -11801,17 +11790,13 @@ class HermesCLI:
|
||||
|
||||
if self._voice_tts:
|
||||
try:
|
||||
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")
|
||||
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,
|
||||
)
|
||||
_tts_cfg = _load_tts_cfg()
|
||||
if _get_prov(_tts_cfg) == "elevenlabs":
|
||||
# Verify both ElevenLabs SDK and audio output are available
|
||||
@@ -13994,7 +13979,12 @@ class HermesCLI:
|
||||
reserved_below = 6
|
||||
|
||||
available = max(0, term_rows - reserved_below)
|
||||
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
|
||||
# The compact decision must reserve room for at least one question
|
||||
# row on top of the choices, otherwise full chrome (3 blank
|
||||
# separators) gets kept when there is no room for it and the panel
|
||||
# overflows the viewport — HSplit then clips the panel's tail,
|
||||
# silently dropping the choices (the reported bug).
|
||||
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
|
||||
|
||||
use_compact_chrome = mandatory_full > available
|
||||
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
|
||||
@@ -14002,9 +13992,24 @@ class HermesCLI:
|
||||
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
|
||||
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
|
||||
|
||||
# When the choices alone (plus compact chrome) already exceed the
|
||||
# viewport, drop the question entirely — the choices are the only
|
||||
# thing the user must see to make a selection. Without this the
|
||||
# question would still claim its 1-row floor above and push the
|
||||
# tail of the choices off-screen (HSplit clips the overflow).
|
||||
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
|
||||
if choices_overflow:
|
||||
max_question_rows = 0
|
||||
|
||||
question_wrapped = _wrap_panel_text(question, inner_text_width)
|
||||
if len(question_wrapped) > max_question_rows:
|
||||
keep = max(1, max_question_rows - 1)
|
||||
if max_question_rows <= 0:
|
||||
question_wrapped = []
|
||||
elif len(question_wrapped) > max_question_rows:
|
||||
# The truncation marker is itself a row, so it must count
|
||||
# against the budget. With a 1-row budget there is no room for
|
||||
# both a question line and the marker — show the marker alone
|
||||
# so the rendered question never exceeds max_question_rows.
|
||||
keep = max(0, max_question_rows - 1)
|
||||
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
|
||||
|
||||
lines = []
|
||||
|
||||
@@ -1605,6 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
|
||||
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
|
||||
await queue.put(_event_payload("assistant.completed", {
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
@@ -1617,6 +1618,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
"completed": True,
|
||||
"messages": turn_messages,
|
||||
"usage": usage,
|
||||
}))
|
||||
except Exception as exc:
|
||||
@@ -3329,6 +3331,44 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return len(prior)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _turn_transcript_messages(
|
||||
cls,
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return this turn's assistant/tool messages in client-safe shape.
|
||||
|
||||
The streaming SSE contract delivers all assistant text as
|
||||
``assistant.delta`` events under one ``message_id`` interleaved with
|
||||
``tool.*`` events, and a single ``assistant.completed`` carrying only
|
||||
the final reply. A client that accumulates deltas into one buffer
|
||||
cannot reconstruct *intermediate* assistant text segments that preceded
|
||||
tool calls — so when the page is re-opened mid/post-stream those
|
||||
segments appear lost, even though state.db persisted them correctly.
|
||||
|
||||
Emitting the authoritative per-turn transcript on ``run.completed`` lets
|
||||
any SSE consumer reconcile its live view against ground truth without a
|
||||
separate ``GET /messages`` round-trip. Purely additive: clients that
|
||||
ignore the field are unaffected. Refs #34703.
|
||||
"""
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
if not isinstance(agent_messages, list) or not agent_messages:
|
||||
return []
|
||||
start = cls._response_messages_turn_start_index(
|
||||
conversation_history, user_message, result
|
||||
)
|
||||
turn = agent_messages[start:]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for msg in turn:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") not in {"assistant", "tool"}:
|
||||
continue
|
||||
out.append(cls._message_response(msg))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
+82
-28
@@ -1131,6 +1131,75 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
|
||||
#
|
||||
# Both extractors that turn response text into native attachments derive their
|
||||
# extension set from this tuple:
|
||||
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
|
||||
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
|
||||
#
|
||||
# Historically these two carried independently-maintained extension lists.
|
||||
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
|
||||
# ``extract_local_files`` had a broad one. Combined with the unconditional
|
||||
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
|
||||
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
|
||||
# match, got stripped from the body by the loose cleanup regex, and was then
|
||||
# invisible to extract_local_files — the file was never delivered (issue
|
||||
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
|
||||
# from the same set means a tag is only stripped when its extension is one we
|
||||
# can actually deliver, so an unknown-extension path survives in the body
|
||||
# instead of vanishing.
|
||||
#
|
||||
# Covers images (inline), video (inline where supported), audio (voice/audio),
|
||||
# documents/spreadsheets/presentations (send_document), archives, and rendered
|
||||
# web output. The dispatch partition (image vs video vs document) lives in
|
||||
# ``gateway/run.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
|
||||
# Images (embed inline)
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
|
||||
# Video (embed inline where supported)
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm",
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
|
||||
# Documents (uploaded as file attachments)
|
||||
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
|
||||
# Spreadsheets / data
|
||||
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
|
||||
# Presentations
|
||||
".pptx", ".ppt", ".odp", ".key",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
|
||||
# Web / rendered output
|
||||
".html", ".htm",
|
||||
)
|
||||
|
||||
# Regex alternation fragment of bare extensions (no leading dot), e.g.
|
||||
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
|
||||
# longest-first so the alternation never matches a shorter ext as a prefix of
|
||||
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
|
||||
_MEDIA_EXT_ALTERNATION = "|".join(
|
||||
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
|
||||
)
|
||||
|
||||
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
|
||||
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
|
||||
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
|
||||
# an unknown extension is left in the text so it can still be picked up by the
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def get_document_cache_dir() -> Path:
|
||||
"""Return the document cache directory, creating it if it doesn't exist."""
|
||||
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2542,10 +2611,10 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = cleaned.replace("[[as_document]]", "")
|
||||
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
|
||||
)
|
||||
# and quoted/backticked paths for LLM-formatted outputs. The extension
|
||||
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
|
||||
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
|
||||
media_pattern = MEDIA_TAG_CLEANUP_RE
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
|
||||
@@ -2591,24 +2660,7 @@ class BasePlatformAdapter(ABC):
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
# Images (embed inline)
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
|
||||
# Video (embed inline where supported)
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
|
||||
# Documents (uploaded as file attachments)
|
||||
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
|
||||
# Spreadsheets / data
|
||||
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||
# Presentations
|
||||
'.pptx', '.ppt', '.odp', '.key',
|
||||
# Archives
|
||||
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
|
||||
# Web / rendered output
|
||||
'.html', '.htm',
|
||||
)
|
||||
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
@@ -3729,7 +3781,12 @@ class BasePlatformAdapter(ABC):
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
@@ -3750,11 +3807,8 @@ class BasePlatformAdapter(ABC):
|
||||
and text_content
|
||||
and not media_files):
|
||||
try:
|
||||
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():
|
||||
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
|
||||
if check_tts_requirements():
|
||||
import json as _json
|
||||
speech_text = self.prepare_tts_text(text_content)
|
||||
if not speech_text:
|
||||
|
||||
+7
-2
@@ -113,12 +113,17 @@ DINGTALK_TYPE_MAPPING = {
|
||||
def check_dingtalk_requirements() -> bool:
|
||||
"""Check if DingTalk dependencies are available and configured.
|
||||
|
||||
Since this is a separate package, deps are guaranteed by the package
|
||||
manager. Just verify the SDK can be imported and env vars are set.
|
||||
Lazy-installs dingtalk-stream via ``tools.lazy_deps.ensure("platform.dingtalk")``
|
||||
on first call if not present.
|
||||
"""
|
||||
global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage
|
||||
global HTTPX_AVAILABLE, httpx
|
||||
if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("platform.dingtalk", prompt=False)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import dingtalk_stream as _ds
|
||||
from dingtalk_stream import ChatbotMessage as _CM
|
||||
+74
-55
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import hmac
|
||||
import itertools
|
||||
@@ -1345,70 +1346,71 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
||||
def check_feishu_requirements() -> bool:
|
||||
"""Check if Feishu/Lark dependencies are available.
|
||||
|
||||
Since this is a separate package, deps are guaranteed by the package
|
||||
manager. Just verify the SDK can be imported.
|
||||
Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
|
||||
on first call if not present. Rebinds all module-level globals on success.
|
||||
"""
|
||||
if FEISHU_AVAILABLE:
|
||||
return True
|
||||
|
||||
try:
|
||||
import lark_oapi as _lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest as _GAR
|
||||
def _import():
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest as _CFR, CreateFileRequestBody as _CFRB,
|
||||
CreateImageRequest as _CIR, CreateImageRequestBody as _CIRB,
|
||||
CreateMessageRequest as _CMR, CreateMessageRequestBody as _CMRB,
|
||||
GetChatRequest as _GCR, GetMessageRequest as _GMR, GetMessageResourceRequest as _GMRR,
|
||||
P2ImMessageMessageReadV1 as _P2,
|
||||
ReplyMessageRequest as _RMR, ReplyMessageRequestBody as _RMRB,
|
||||
UpdateMessageRequest as _UMR, UpdateMessageRequestBody as _UMRB,
|
||||
CreateFileRequest, CreateFileRequestBody,
|
||||
CreateImageRequest, CreateImageRequestBody,
|
||||
CreateMessageRequest, CreateMessageRequestBody,
|
||||
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
|
||||
P2ImMessageMessageReadV1,
|
||||
ReplyMessageRequest, ReplyMessageRequestBody,
|
||||
UpdateMessageRequest, UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType as _AT, HttpMethod as _HM
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN as _FD, LARK_DOMAIN as _LD
|
||||
from lark_oapi.core.model import BaseRequest as _BR
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard as _CBC, P2CardActionTriggerResponse as _P2R,
|
||||
CallBackCard, P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler as _EDH
|
||||
from lark_oapi.ws import Client as _FWSC
|
||||
except ImportError:
|
||||
return False
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
return {
|
||||
"lark": lark,
|
||||
"GetApplicationRequest": GetApplicationRequest,
|
||||
"CreateFileRequest": CreateFileRequest,
|
||||
"CreateFileRequestBody": CreateFileRequestBody,
|
||||
"CreateImageRequest": CreateImageRequest,
|
||||
"CreateImageRequestBody": CreateImageRequestBody,
|
||||
"CreateMessageRequest": CreateMessageRequest,
|
||||
"CreateMessageRequestBody": CreateMessageRequestBody,
|
||||
"GetChatRequest": GetChatRequest,
|
||||
"GetMessageRequest": GetMessageRequest,
|
||||
"GetMessageResourceRequest": GetMessageResourceRequest,
|
||||
"P2ImMessageMessageReadV1": P2ImMessageMessageReadV1,
|
||||
"ReplyMessageRequest": ReplyMessageRequest,
|
||||
"ReplyMessageRequestBody": ReplyMessageRequestBody,
|
||||
"UpdateMessageRequest": UpdateMessageRequest,
|
||||
"UpdateMessageRequestBody": UpdateMessageRequestBody,
|
||||
"AccessTokenType": AccessTokenType,
|
||||
"HttpMethod": HttpMethod,
|
||||
"FEISHU_DOMAIN": FEISHU_DOMAIN,
|
||||
"LARK_DOMAIN": LARK_DOMAIN,
|
||||
"BaseRequest": BaseRequest,
|
||||
"CallBackCard": CallBackCard,
|
||||
"P2CardActionTriggerResponse": P2CardActionTriggerResponse,
|
||||
"EventDispatcherHandler": EventDispatcherHandler,
|
||||
"FeishuWSClient": FeishuWSClient,
|
||||
"FEISHU_AVAILABLE": True,
|
||||
}
|
||||
|
||||
globals().update({
|
||||
"lark": _lark,
|
||||
"GetApplicationRequest": _GAR,
|
||||
"CreateFileRequest": _CFR,
|
||||
"CreateFileRequestBody": _CFRB,
|
||||
"CreateImageRequest": _CIR,
|
||||
"CreateImageRequestBody": _CIRB,
|
||||
"CreateMessageRequest": _CMR,
|
||||
"CreateMessageRequestBody": _CMRB,
|
||||
"GetChatRequest": _GCR,
|
||||
"GetMessageRequest": _GMR,
|
||||
"GetMessageResourceRequest": _GMRR,
|
||||
"P2ImMessageMessageReadV1": _P2,
|
||||
"ReplyMessageRequest": _RMR,
|
||||
"ReplyMessageRequestBody": _RMRB,
|
||||
"UpdateMessageRequest": _UMR,
|
||||
"UpdateMessageRequestBody": _UMRB,
|
||||
"AccessTokenType": _AT,
|
||||
"HttpMethod": _HM,
|
||||
"FEISHU_DOMAIN": _FD,
|
||||
"LARK_DOMAIN": _LD,
|
||||
"BaseRequest": _BR,
|
||||
"CallBackCard": _CBC,
|
||||
"P2CardActionTriggerResponse": _P2R,
|
||||
"EventDispatcherHandler": _EDH,
|
||||
"FeishuWSClient": _FWSC,
|
||||
"FEISHU_AVAILABLE": True,
|
||||
})
|
||||
return True
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
|
||||
|
||||
|
||||
class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1446,7 +1448,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._pending_inbound_lock = threading.Lock()
|
||||
self._pending_drain_scheduled = False
|
||||
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
||||
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
|
||||
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -2460,7 +2462,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
logging, and reaction. Scheduling follows the same
|
||||
``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
|
||||
"""
|
||||
from .feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
|
||||
loop = self._loop
|
||||
if not self._loop_accepts_callbacks(loop):
|
||||
@@ -2836,11 +2838,28 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# =========================================================================
|
||||
|
||||
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
|
||||
|
||||
Bounded with LRU eviction so a long-running gateway that sees many
|
||||
distinct chats does not grow ``_chat_locks`` without limit. Locks that
|
||||
are currently held are never evicted; if every entry is locked we fall
|
||||
back to dropping the least-recently-used one.
|
||||
"""
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
if lock is not None:
|
||||
self._chat_locks.move_to_end(chat_id)
|
||||
return lock
|
||||
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
|
||||
evicted = False
|
||||
for key in list(self._chat_locks):
|
||||
if not self._chat_locks[key].locked():
|
||||
self._chat_locks.pop(key)
|
||||
evicted = True
|
||||
break
|
||||
if not evicted:
|
||||
self._chat_locks.pop(next(iter(self._chat_locks)))
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
+1
-1
@@ -1164,7 +1164,7 @@ async def handle_drive_comment_event(
|
||||
)
|
||||
|
||||
# Access control
|
||||
from .feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
|
||||
from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
|
||||
|
||||
comments_cfg = load_config()
|
||||
rule = resolve_rule(comments_cfg, file_type, file_token)
|
||||
+48
-10
@@ -240,8 +240,13 @@ def _check_e2ee_deps() -> bool:
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used.
|
||||
|
||||
Since this is a separate package, deps are guaranteed by the package
|
||||
manager. Just verify the SDK can be imported and env vars are set.
|
||||
Lazy-installs the full ``platform.matrix`` feature group via
|
||||
``tools.lazy_deps.ensure_and_bind`` whenever any of the declared
|
||||
packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is
|
||||
missing — not just mautrix itself. Previously this short-circuited on
|
||||
``import mautrix``, which left the other four packages uninstalled
|
||||
forever and broke E2EE connect with ``No module named 'asyncpg'``
|
||||
(#31116). Rebinds module-level type globals on success.
|
||||
"""
|
||||
token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
password = os.getenv("MATRIX_PASSWORD", "")
|
||||
@@ -254,15 +259,48 @@ def check_matrix_requirements() -> bool:
|
||||
logger.warning("Matrix: MATRIX_HOMESERVER not set")
|
||||
return False
|
||||
|
||||
# Try importing the mautrix types to verify the SDK is present.
|
||||
# Check whether any package in the platform.matrix feature group is
|
||||
# missing. ``feature_missing`` is cheap (per-spec importlib.metadata
|
||||
# lookups) and correctly handles ``mautrix[encryption]`` by stripping
|
||||
# the extras marker before checking the bare package.
|
||||
try:
|
||||
from mautrix.types import ( # noqa: F401
|
||||
ContentURI, EventID, EventType, PaginationDirection,
|
||||
PresenceState, RoomCreatePreset, RoomID, SyncToken,
|
||||
TrustState, UserID,
|
||||
)
|
||||
except ImportError:
|
||||
return False
|
||||
from tools.lazy_deps import feature_missing, ensure_and_bind
|
||||
missing = feature_missing("platform.matrix")
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Matrix: lazy_deps lookup failed: %s", exc)
|
||||
missing = ()
|
||||
ensure_and_bind = None # type: ignore[assignment]
|
||||
|
||||
if missing or ensure_and_bind is None:
|
||||
def _import():
|
||||
from mautrix.types import (
|
||||
ContentURI, EventID, EventType, PaginationDirection,
|
||||
PresenceState, RoomCreatePreset, RoomID, SyncToken,
|
||||
TrustState, UserID,
|
||||
)
|
||||
return {
|
||||
"ContentURI": ContentURI,
|
||||
"EventID": EventID,
|
||||
"EventType": EventType,
|
||||
"PaginationDirection": PaginationDirection,
|
||||
"PresenceState": PresenceState,
|
||||
"RoomCreatePreset": RoomCreatePreset,
|
||||
"RoomID": RoomID,
|
||||
"SyncToken": SyncToken,
|
||||
"TrustState": TrustState,
|
||||
"UserID": UserID,
|
||||
}
|
||||
|
||||
if ensure_and_bind is None:
|
||||
return False
|
||||
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
|
||||
logger.warning(
|
||||
"Matrix: required packages not installed (%s). "
|
||||
"Run: pip install 'mautrix[encryption]' asyncpg aiosqlite "
|
||||
"Markdown aiohttp-socks",
|
||||
", ".join(missing) if missing else "platform.matrix",
|
||||
)
|
||||
return False
|
||||
|
||||
# If encryption is requested, verify E2EE deps are available at startup
|
||||
# rather than silently degrading to plaintext-only at connect time.
|
||||
+20
-17
@@ -30,6 +30,10 @@ except ImportError:
|
||||
AsyncSocketModeHandler = Any
|
||||
AsyncWebClient = Any
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
from gateway.platforms.base import (
|
||||
@@ -71,28 +75,27 @@ class _ThreadContextCache:
|
||||
def check_slack_requirements() -> bool:
|
||||
"""Check if Slack dependencies are available.
|
||||
|
||||
Since this is a separate package, deps are guaranteed by the package
|
||||
manager. Just verify the SDK can be imported.
|
||||
Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")``
|
||||
on first call if not present. Rebinds all module-level globals on success.
|
||||
"""
|
||||
if SLACK_AVAILABLE:
|
||||
return True
|
||||
|
||||
try:
|
||||
from slack_bolt.async_app import AsyncApp as _AsyncApp
|
||||
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler as _ASMH
|
||||
from slack_sdk.web.async_client import AsyncWebClient as _AWC
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
return False
|
||||
def _import():
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
import aiohttp
|
||||
return {
|
||||
"AsyncApp": AsyncApp,
|
||||
"AsyncSocketModeHandler": AsyncSocketModeHandler,
|
||||
"AsyncWebClient": AsyncWebClient,
|
||||
"aiohttp": aiohttp,
|
||||
"SLACK_AVAILABLE": True,
|
||||
}
|
||||
|
||||
globals().update({
|
||||
"AsyncApp": _AsyncApp,
|
||||
"AsyncSocketModeHandler": _ASMH,
|
||||
"AsyncWebClient": _AWC,
|
||||
"aiohttp": _aiohttp,
|
||||
"SLACK_AVAILABLE": True,
|
||||
})
|
||||
return True
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
|
||||
|
||||
|
||||
def _extract_text_from_slack_blocks(blocks: list) -> str:
|
||||
+13
-2
@@ -60,6 +60,10 @@ except ImportError:
|
||||
DEFAULT_TYPE = Any
|
||||
ContextTypes = _MockContextTypes
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
@@ -107,8 +111,10 @@ MAX_COMMANDS_PER_SCOPE = 30
|
||||
def check_telegram_requirements() -> bool:
|
||||
"""Check if Telegram dependencies are available.
|
||||
|
||||
Since this is a separate package, deps are guaranteed by the package
|
||||
manager. Just verify the SDK can be imported.
|
||||
If python-telegram-bot is missing, attempts to lazy-install it via
|
||||
``tools.lazy_deps.ensure("platform.telegram")``. After a successful
|
||||
install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True
|
||||
so the adapter's class-level type aliases get rebound.
|
||||
"""
|
||||
global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
|
||||
global InlineKeyboardMarkup, LinkPreviewOptions, Application
|
||||
@@ -116,6 +122,11 @@ def check_telegram_requirements() -> bool:
|
||||
global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest
|
||||
if TELEGRAM_AVAILABLE:
|
||||
return True
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("platform.telegram", prompt=False)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
from telegram import Update as _Update, Bot as _Bot, Message as _Message
|
||||
from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
|
||||
+118
-62
@@ -6365,29 +6365,6 @@ 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).
|
||||
@@ -6401,13 +6378,49 @@ class GatewayRunner:
|
||||
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
|
||||
# Fall through to built-in adapters below
|
||||
|
||||
if platform == Platform.WHATSAPP:
|
||||
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:
|
||||
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():
|
||||
@@ -6436,6 +6449,20 @@ 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,
|
||||
@@ -6460,6 +6487,13 @@ 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():
|
||||
@@ -11616,12 +11650,7 @@ class GatewayRunner:
|
||||
audio_path = None
|
||||
actual_path = None
|
||||
try:
|
||||
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"]
|
||||
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
|
||||
|
||||
tts_text = _strip_markdown_for_tts(text[:4000])
|
||||
if not tts_text:
|
||||
@@ -11714,9 +11743,16 @@ class GatewayRunner:
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
media_files, cleaned = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
# Chain the cleaned text through each extractor (extract_media →
|
||||
# extract_images → extract_local_files) so MEDIA: tags and image URLs
|
||||
# are removed before the bare-path auto-detect runs. Previously the
|
||||
# cleaned text from extract_media was dropped (``_``) and
|
||||
# extract_local_files scanned text that still contained MEDIA: tags,
|
||||
# producing false-positive bare-path matches with the MEDIA: prefix
|
||||
# glued on. This matches the chain order in gateway/platforms/base.py.
|
||||
_, cleaned = adapter.extract_images(cleaned)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||
|
||||
@@ -14912,32 +14948,9 @@ class GatewayRunner:
|
||||
return f"{prefix}\n\n{user_text}"
|
||||
return prefix
|
||||
|
||||
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"]
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
enriched_parts = []
|
||||
for path in audio_paths:
|
||||
try:
|
||||
logger.debug("Transcribing user voice: %s", path)
|
||||
@@ -17472,13 +17485,33 @@ class GatewayRunner:
|
||||
# append any that aren't already present in the final response, so the
|
||||
# adapter's extract_media() can find and deliver the files exactly once.
|
||||
#
|
||||
# Uses path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) instead of index slicing. This is safe even
|
||||
# when context compression shrinks the message list. (Fixes #160)
|
||||
# Scope the scan to THIS turn's tool results only. ``agent_history``
|
||||
# was passed into run_conversation as ``conversation_history``, so the
|
||||
# agent's returned ``messages`` list is ``agent_history`` followed by
|
||||
# the messages produced this turn. Slicing at ``len(agent_history)``
|
||||
# isolates the current turn precisely, so a stale MEDIA: path emitted
|
||||
# by a tool several turns earlier (still present in the full message
|
||||
# list) can never leak onto a later text-only reply. (Fixes #34608)
|
||||
#
|
||||
# Path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) is retained as a secondary guard. It is
|
||||
# also the sole guard on the fallback branch taken when mid-run
|
||||
# context compression shrinks the message list below the original
|
||||
# history length, preserving the compression-safe behaviour of #160.
|
||||
if "MEDIA:" not in final_response:
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
for msg in result.get("messages", []):
|
||||
_all_msgs = result.get("messages", [])
|
||||
_history_len = len(agent_history)
|
||||
# Only trust the slice boundary when the message list still
|
||||
# contains the full history prefix. Mid-run compression can
|
||||
# rewrite/shrink the list; in that case fall back to scanning
|
||||
# everything and rely on _history_media_paths for dedup.
|
||||
if _history_len and len(_all_msgs) >= _history_len:
|
||||
_scan_msgs = _all_msgs[_history_len:]
|
||||
else:
|
||||
_scan_msgs = _all_msgs
|
||||
for msg in _scan_msgs:
|
||||
if msg.get("role") in {"tool", "function"}:
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
@@ -18436,7 +18469,10 @@ def _run_planned_stop_watcher(
|
||||
poll_interval: seconds between marker checks. 0.5s gives a
|
||||
responsive shutdown without burning CPU.
|
||||
"""
|
||||
from gateway.status import _get_planned_stop_marker_path
|
||||
from gateway.status import (
|
||||
_get_planned_stop_marker_path,
|
||||
planned_stop_marker_targets_self,
|
||||
)
|
||||
marker_path = _get_planned_stop_marker_path()
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
@@ -18445,6 +18481,26 @@ def _run_planned_stop_watcher(
|
||||
and not getattr(runner, "_draining", False)
|
||||
and getattr(runner, "_running", False)
|
||||
):
|
||||
# A marker existing is NOT sufficient — it may have been
|
||||
# written for a PREVIOUS gateway instance (different PID)
|
||||
# and left behind because that process exited before the
|
||||
# CLI's stop() could clean it up. Firing the handler on a
|
||||
# stale/foreign marker drives the gateway into shutdown,
|
||||
# then consume_planned_stop_marker_for_self() correctly
|
||||
# reports a PID mismatch — but by then we're already
|
||||
# stopping, so it's logged as an unexpected "UNKNOWN" exit
|
||||
# and the watchdog crash-loops the gateway (issue #34597,
|
||||
# a regression from PR #33798 which added this watcher
|
||||
# without the PID check).
|
||||
#
|
||||
# Only fire when the marker actually targets us. The probe
|
||||
# is non-destructive on a match (the handler does the
|
||||
# authoritative consume on the loop thread) and self-heals
|
||||
# by unlinking stale/malformed markers so they cannot wedge
|
||||
# a freshly booted gateway.
|
||||
if not planned_stop_marker_targets_self():
|
||||
stop_event.wait(poll_interval)
|
||||
continue
|
||||
# Drive the same path as a real signal handler.
|
||||
# Pass signal=None — the handler tolerates that and consumes
|
||||
# the marker via consume_planned_stop_marker_for_self,
|
||||
|
||||
+80
-6
@@ -816,12 +816,24 @@ def _consume_pid_marker_for_self(
|
||||
|
||||
our_pid = os.getpid()
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
matches = (
|
||||
target_pid == our_pid
|
||||
and target_start_time is not None
|
||||
and our_start_time is not None
|
||||
and target_start_time == our_start_time
|
||||
)
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform the planned-stop watcher exists for). Requiring a non-None
|
||||
# match there would make every consume return False, so a legitimate
|
||||
# ``hermes gateway stop`` on Windows would be misclassified as an
|
||||
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
|
||||
# manager. So: when both start_times are known they must match; when
|
||||
# either is unknown, fall back to PID equality alone (bounded by the
|
||||
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
|
||||
# so the watcher's non-destructive probe and this authoritative
|
||||
# consume agree on every platform (issue #34597).
|
||||
if target_pid != our_pid:
|
||||
matches = False
|
||||
elif target_start_time is not None and our_start_time is not None:
|
||||
matches = target_start_time == our_start_time
|
||||
else:
|
||||
matches = True
|
||||
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def planned_stop_marker_targets_self() -> bool:
|
||||
"""Return True only when a live planned-stop marker names the current process.
|
||||
|
||||
This is a **non-destructive** probe used by the watcher thread
|
||||
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
|
||||
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
|
||||
it never unlinks a marker that matches us — the shutdown handler does
|
||||
the authoritative consume on its own thread.
|
||||
|
||||
It *does* clean up markers that can never apply to this process:
|
||||
malformed markers and markers older than the TTL are unlinked so a
|
||||
stale file left behind by a previous gateway instance cannot wedge
|
||||
the new one. Markers naming a different PID/start_time are left in
|
||||
place (they may still be consumed legitimately by the process they
|
||||
name) but report False here.
|
||||
|
||||
Returns False (without raising) on any read/parse error.
|
||||
"""
|
||||
path = _get_planned_stop_marker_path()
|
||||
record = _read_json_file(path)
|
||||
if not record:
|
||||
return False
|
||||
|
||||
try:
|
||||
target_pid = int(record["target_pid"])
|
||||
target_start_time = record.get("target_start_time")
|
||||
written_at = record.get("written_at") or ""
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# Malformed marker can never match anyone — drop it.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
|
||||
# A marker this old is past its useful life regardless of target —
|
||||
# clean it up so it cannot crash-loop a freshly booted gateway.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
our_pid = os.getpid()
|
||||
if target_pid != our_pid:
|
||||
return False
|
||||
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform this watcher exists for). Requiring a non-None match there
|
||||
# would make the watcher never fire and re-break the #33778 Windows
|
||||
# session-resume path. So: when both start_times are known they must
|
||||
# match; when either is unknown, fall back to PID equality alone
|
||||
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
if target_start_time is not None and our_start_time is not None:
|
||||
return target_start_time == our_start_time
|
||||
return True
|
||||
|
||||
|
||||
def clear_planned_stop_marker() -> None:
|
||||
"""Remove the planned-stop marker unconditionally."""
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
|
||||
from gateway.platforms.base import _custom_unit_to_cp
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
from gateway.config import (
|
||||
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
|
||||
@@ -645,10 +646,13 @@ class GatewayStreamConsumer:
|
||||
except Exception as e:
|
||||
logger.error("Stream consumer error: %s", e)
|
||||
|
||||
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
|
||||
# Matches the simple cleanup regex used by the non-streaming path in
|
||||
# gateway/platforms/base.py for post-processing.
|
||||
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
|
||||
# Strip MEDIA:<path> tags before display. Uses the shared anchored
|
||||
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
|
||||
# path ends in a deliverable extension are removed, so an unknown-extension
|
||||
# path stays visible instead of being silently dropped (issue #34517).
|
||||
# Streaming and non-streaming paths share the same regex, so a tag is
|
||||
# treated identically whichever path delivered the text.
|
||||
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
|
||||
|
||||
@staticmethod
|
||||
def _clean_for_display(text: str) -> str:
|
||||
|
||||
+9
-18
@@ -1580,10 +1580,8 @@ 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.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():
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
if has_aws_credentials():
|
||||
return "bedrock"
|
||||
except ImportError:
|
||||
pass # boto3 not installed — skip Bedrock auto-detection
|
||||
@@ -5732,13 +5730,8 @@ 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.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"}
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||||
except ImportError:
|
||||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||||
return {"logged_in": False}
|
||||
@@ -5777,13 +5770,11 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
|
||||
|
||||
if auth_mode == "entra_id":
|
||||
try:
|
||||
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")
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
installed = has_azure_identity_installed()
|
||||
entra_cfg = {}
|
||||
if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict):
|
||||
|
||||
+11
-18
@@ -221,12 +221,9 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "anthropic":
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
run_hermes_oauth_login_pure = _anthropic_ns.get("run_hermes_oauth_login_pure")
|
||||
if not run_hermes_oauth_login_pure:
|
||||
raise SystemExit("Anthropic plugin not loaded — cannot run OAuth login.")
|
||||
creds = run_hermes_oauth_login_pure()
|
||||
from agent import anthropic_adapter as anthropic_mod
|
||||
|
||||
creds = anthropic_mod.run_hermes_oauth_login_pure()
|
||||
if not creds:
|
||||
raise SystemExit("Anthropic OAuth login did not return credentials.")
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
@@ -548,12 +545,8 @@ def _interactive_auth() -> None:
|
||||
|
||||
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
|
||||
try:
|
||||
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():
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
auth_source = resolve_aws_auth_env_var() or "unknown"
|
||||
region = resolve_bedrock_region()
|
||||
print(f"bedrock (AWS SDK credential chain):")
|
||||
@@ -580,12 +573,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.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")
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
describe_active_credential,
|
||||
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):
|
||||
|
||||
@@ -670,6 +670,105 @@ def restore_quick_snapshot(
|
||||
return restored > 0
|
||||
|
||||
|
||||
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
|
||||
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
|
||||
_CRON_JOBS_REL = "cron/jobs.json"
|
||||
|
||||
|
||||
def _count_cron_jobs(path: Path) -> Optional[int]:
|
||||
"""Return the number of cron jobs stored in ``path``.
|
||||
|
||||
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
|
||||
A legacy bare-list shape (``[...]``) is also honoured.
|
||||
|
||||
Returns:
|
||||
The job count for any *valid, readable* JSON document, or ``None`` if
|
||||
the file is missing or cannot be parsed. ``None`` means "unknown" —
|
||||
callers must not treat it as "zero jobs", because acting on an
|
||||
unreadable file could mask a real corruption the user needs to see.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
return len(jobs) if isinstance(jobs, list) else None
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return None
|
||||
|
||||
|
||||
def restore_cron_jobs_if_emptied(
|
||||
snapshot_id: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Safety net for silent cron-job loss across ``hermes update``.
|
||||
|
||||
Config-version migrations have been observed to leave ``cron/jobs.json``
|
||||
valid-but-empty after an update, silently dropping every scheduled job
|
||||
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
|
||||
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
|
||||
|
||||
This compares the *current* job count against the pre-update snapshot. If
|
||||
the live file now has **zero** jobs while the snapshot captured **one or
|
||||
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
|
||||
|
||||
The check is deliberately conservative — it only ever restores when there
|
||||
is unambiguous evidence of loss (snapshot had jobs, live file has none),
|
||||
so a user who genuinely deleted all their jobs during/after the update is
|
||||
never second-guessed, and an unreadable live file (count ``None``) is left
|
||||
untouched so real corruption still surfaces.
|
||||
|
||||
Args:
|
||||
snapshot_id: The pre-update quick-snapshot id (from
|
||||
:func:`create_quick_snapshot`).
|
||||
hermes_home: Override for the Hermes home directory (tests).
|
||||
|
||||
Returns:
|
||||
``None`` when no action was taken (the common, healthy path). On a
|
||||
successful restore, a dict ``{"restored": True, "job_count": N,
|
||||
"snapshot_id": ...}`` so the caller can warn the user.
|
||||
"""
|
||||
if not snapshot_id:
|
||||
return None
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
live_path = home / _CRON_JOBS_REL
|
||||
|
||||
live_count = _count_cron_jobs(live_path)
|
||||
# Only act when the live file is readable AND empty. ``None`` (missing or
|
||||
# unparseable) is intentionally left alone — that's a different failure
|
||||
# mode the user should see rather than have papered over.
|
||||
if live_count is None or live_count > 0:
|
||||
return None
|
||||
|
||||
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
|
||||
snap_count = _count_cron_jobs(snap_path)
|
||||
if not snap_count: # None or 0 — nothing worth restoring
|
||||
return None
|
||||
|
||||
try:
|
||||
live_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(snap_path, live_path)
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error(
|
||||
"Cron jobs were emptied during update but auto-restore failed: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Restored %d cron job(s) from pre-update snapshot %s "
|
||||
"(cron/jobs.json was emptied during migration)",
|
||||
snap_count,
|
||||
snapshot_id,
|
||||
)
|
||||
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
|
||||
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
|
||||
if not root.exists():
|
||||
|
||||
+38
-24
@@ -27,6 +27,7 @@ from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
||||
_PROVIDER_ENV_HINTS = (
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
@@ -52,11 +53,14 @@ _PROVIDER_ENV_HINTS = (
|
||||
"TOKENHUB_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
from hermes_constants import is_termux as _is_termux
|
||||
|
||||
|
||||
def _python_install_cmd() -> str:
|
||||
return "python -m pip install" if _is_termux() else "uv pip install"
|
||||
|
||||
|
||||
def _system_package_install_cmd(pkg: str) -> str:
|
||||
if _is_termux():
|
||||
return f"pkg install {pkg}"
|
||||
@@ -64,6 +68,7 @@ def _system_package_install_cmd(pkg: str) -> str:
|
||||
return f"brew install {pkg}"
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
@@ -71,6 +76,7 @@ def _safe_which(cmd: str) -> str | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
@@ -81,6 +87,7 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps.append(f"{step + 1}) agent-browser install")
|
||||
return steps
|
||||
|
||||
|
||||
def _termux_install_all_fallback_notes() -> list[str]:
|
||||
return [
|
||||
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
|
||||
@@ -89,10 +96,12 @@ def _termux_install_all_fallback_notes() -> list[str]:
|
||||
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
|
||||
]
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
|
||||
|
||||
def _honcho_is_configured_for_doctor() -> bool:
|
||||
"""Return True when Honcho is configured, even if this process has no active session."""
|
||||
try:
|
||||
@@ -103,6 +112,7 @@ def _honcho_is_configured_for_doctor() -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
"""Return True when Kanban is unavailable only because this is not a worker process."""
|
||||
if item.get("name") != "kanban":
|
||||
@@ -113,12 +123,14 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
tools = item.get("tools") or []
|
||||
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
|
||||
|
||||
|
||||
def _doctor_tool_availability_detail(toolset: str) -> str:
|
||||
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
|
||||
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
|
||||
"""Adjust runtime-gated tool availability for doctor diagnostics."""
|
||||
updated_available = list(available)
|
||||
@@ -136,6 +148,7 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable:
|
||||
updated_unavailable.append(item)
|
||||
return updated_available, updated_unavailable
|
||||
|
||||
|
||||
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
|
||||
"""Return True when a direct API-key probe failure is non-blocking.
|
||||
|
||||
@@ -165,6 +178,7 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def check_ok(text: str, detail: str = ""):
|
||||
print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
|
||||
|
||||
@@ -177,16 +191,19 @@ def check_fail(text: str, detail: str = ""):
|
||||
def check_info(text: str):
|
||||
print(f" {color('→', Colors.CYAN)} {text}")
|
||||
|
||||
|
||||
def _section(title: str) -> None:
|
||||
"""Print a doctor section banner: blank line + bold cyan ◆ title."""
|
||||
print()
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
|
||||
"""Emit a check_fail and append the corresponding fix instruction."""
|
||||
check_fail(text, detail)
|
||||
issues.append(fix)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
@@ -234,6 +251,7 @@ def _check_s6_supervision(issues: list[str]) -> None:
|
||||
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "")
|
||||
)
|
||||
|
||||
|
||||
def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
"""Warn when a systemd user gateway service will stop after logout.
|
||||
|
||||
@@ -277,8 +295,10 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
else:
|
||||
check_warn("Could not verify systemd linger", f"({linger_detail})")
|
||||
|
||||
|
||||
_APIKEY_PROVIDERS_CACHE: list | None = None
|
||||
|
||||
|
||||
def _build_apikey_providers_list() -> list:
|
||||
"""Build the API-key provider health-check list once and cache it.
|
||||
|
||||
@@ -370,6 +390,7 @@ def _build_apikey_providers_list() -> list:
|
||||
pass
|
||||
return _static
|
||||
|
||||
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
@@ -1453,15 +1474,12 @@ def run_doctor(args):
|
||||
return _ConnectivityResult("Anthropic API", [], [])
|
||||
try:
|
||||
import httpx
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
|
||||
# _COMMON_BETAS and _CONTEXT_1M_BETA are now in core
|
||||
from agent.anthropic_format import _COMMON_BETAS, _CONTEXT_1M_BETA
|
||||
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
|
||||
|
||||
if not all([_is_oauth_token, _OAUTH_ONLY_BETAS]):
|
||||
raise ImportError("anthropic provider services not fully registered")
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_COMMON_BETAS,
|
||||
_OAUTH_ONLY_BETAS,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
is_oauth = _is_oauth_token(key)
|
||||
if is_oauth:
|
||||
@@ -1605,13 +1623,11 @@ def run_doctor(args):
|
||||
|
||||
def _probe_bedrock() -> _ConnectivityResult:
|
||||
try:
|
||||
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")
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
)
|
||||
except ImportError:
|
||||
return _ConnectivityResult("AWS Bedrock", [], [])
|
||||
if not has_aws_credentials():
|
||||
@@ -1682,14 +1698,12 @@ def run_doctor(args):
|
||||
return _ConnectivityResult("Azure Foundry (Entra ID)", [], [])
|
||||
|
||||
try:
|
||||
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")
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _ConnectivityResult(
|
||||
"Azure Foundry (Entra ID)",
|
||||
|
||||
+40
-13
@@ -2161,9 +2161,37 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _stable_service_working_dir() -> str:
|
||||
"""Return a WorkingDirectory that will not disappear out from under systemd.
|
||||
|
||||
The gateway does NOT need its cwd to be the source checkout — ``ExecStart``
|
||||
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
|
||||
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
|
||||
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
|
||||
when the unit is generated from a transient checkout — a ``.worktrees/``
|
||||
dir, or a clone that ``hermes update`` later relocates/removes — the path
|
||||
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
|
||||
"Changing to the requested working directory failed") *before* Python
|
||||
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
|
||||
runs and ``Restart=always`` crash-loops forever on a dead directory.
|
||||
|
||||
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
|
||||
it never moves, and it is guaranteed to exist whenever the gateway is
|
||||
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
|
||||
cannot be resolved (it always can in practice).
|
||||
"""
|
||||
try:
|
||||
home = get_hermes_home()
|
||||
if home and Path(home).is_dir():
|
||||
return str(Path(home).resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return str(PROJECT_ROOT)
|
||||
|
||||
|
||||
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
working_dir = _stable_service_working_dir()
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
|
||||
@@ -2192,7 +2220,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
# (e.g. /root/) to the target user's home so the service can
|
||||
# actually access them.
|
||||
python_path = _remap_path_for_user(python_path, home_dir)
|
||||
working_dir = _remap_path_for_user(working_dir, home_dir)
|
||||
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
|
||||
# rather than a remapped source-checkout path that can rot. See
|
||||
# _stable_service_working_dir() for the full rationale.
|
||||
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
|
||||
venv_dir = _remap_path_for_user(venv_dir, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
@@ -2804,7 +2835,10 @@ def _launchd_domain() -> str:
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
# Stable cwd anchor — never the volatile source checkout. See
|
||||
# _stable_service_working_dir() for the rationale (same rot risk applies
|
||||
# to launchd's WorkingDirectory as to systemd's).
|
||||
working_dir = _stable_service_working_dir()
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -4352,9 +4386,7 @@ def _setup_feishu():
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-create ──
|
||||
try:
|
||||
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
|
||||
from gateway.platforms.feishu import qr_register
|
||||
except Exception as exc:
|
||||
print_error(f" Feishu / Lark onboard import failed: {exc}")
|
||||
qr_register = None
|
||||
@@ -4395,13 +4427,8 @@ def _setup_feishu():
|
||||
# Try to probe the bot with manual credentials
|
||||
bot_name = None
|
||||
try:
|
||||
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
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
if bot_info:
|
||||
bot_name = bot_info.get("bot_name")
|
||||
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
|
||||
|
||||
+116
-49
@@ -622,12 +622,10 @@ 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.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")
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
)
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
if creds and (
|
||||
@@ -4104,15 +4102,13 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
|
||||
if use_entra:
|
||||
try:
|
||||
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")
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
build_token_provider,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
except ImportError as exc:
|
||||
print()
|
||||
print(f"⚠ Could not import azure-identity adapter: {exc}")
|
||||
@@ -5426,14 +5422,12 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
|
||||
# 1. Check for AWS credentials
|
||||
try:
|
||||
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")
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
discover_bedrock_models,
|
||||
)
|
||||
except ImportError:
|
||||
print(" ✗ boto3 is not installed. Install it with:")
|
||||
print(" pip install boto3")
|
||||
@@ -5880,13 +5874,11 @@ 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.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 agent.anthropic_adapter import (
|
||||
run_oauth_setup_token,
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
)
|
||||
from hermes_cli.config import (
|
||||
save_anthropic_oauth_token,
|
||||
use_anthropic_claude_code_credentials,
|
||||
@@ -5994,13 +5986,11 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
existing_key = get_anthropic_key()
|
||||
cc_available = False
|
||||
try:
|
||||
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")
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
_is_oauth_token,
|
||||
)
|
||||
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if cc_creds and is_claude_code_token_valid(cc_creds):
|
||||
@@ -8110,13 +8100,71 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
|
||||
|
||||
|
||||
def _refresh_active_lazy_features() -> None:
|
||||
"""No-op — lazy deps removed.
|
||||
"""Refresh lazy-installed backends after a code 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.
|
||||
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.
|
||||
"""
|
||||
pass
|
||||
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.")
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
@@ -9077,12 +9125,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
pre_update_snapshot_id = None
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snap_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_id}")
|
||||
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
@@ -9419,6 +9468,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
print(" ✓ Configuration is up to date")
|
||||
|
||||
# Safety net: config-version migrations have been observed to leave
|
||||
# cron/jobs.json valid-but-empty, silently dropping every scheduled
|
||||
# job (issue #34600). If the live file is now empty while the
|
||||
# pre-update snapshot held jobs, restore it and warn loudly.
|
||||
try:
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
|
||||
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
|
||||
if cron_restore:
|
||||
print()
|
||||
print(
|
||||
" ⚠️ cron/jobs.json was emptied during this update — "
|
||||
f"restored {cron_restore['job_count']} job(s) from "
|
||||
f"pre-update snapshot {cron_restore['snapshot_id']}."
|
||||
)
|
||||
except Exception as exc:
|
||||
# Never let the cron safety net break an otherwise-good update.
|
||||
logger.debug("Cron jobs auto-restore check failed: %s", exc)
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
@@ -10514,11 +10582,10 @@ def cmd_profile(args):
|
||||
if collision:
|
||||
print(f"Error: {collision}")
|
||||
sys.exit(1)
|
||||
wrapper_path = create_wrapper_script(alias_name)
|
||||
wrapper_path = create_wrapper_script(
|
||||
alias_name, target=name if custom_name else None
|
||||
)
|
||||
if wrapper_path:
|
||||
# If custom name, write the profile name into the wrapper
|
||||
if custom_name:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
|
||||
print(f"✓ Alias created: {wrapper_path}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
|
||||
@@ -205,6 +205,22 @@ def _probe_single_server(
|
||||
return tools_found
|
||||
|
||||
|
||||
def _oauth_tokens_present(name: str) -> bool:
|
||||
"""Return True if an OAuth token file exists on disk for ``name``.
|
||||
|
||||
Used after ``hermes mcp login`` to distinguish a genuine authentication
|
||||
from a probe that succeeded only because the server allowed
|
||||
initialize/tools-list without auth (so no token was ever acquired).
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
return HermesTokenStorage(name).has_cached_tokens()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
|
||||
# Be permissive on unexpected errors: don't block a real success.
|
||||
return True
|
||||
|
||||
|
||||
def _unwrap_exception_group(exc: BaseException) -> Exception:
|
||||
"""Extract the root-cause exception from anyio TaskGroup wrappers.
|
||||
|
||||
@@ -631,6 +647,36 @@ def cmd_mcp_login(args):
|
||||
# Probe triggers the OAuth flow (browser redirect + callback capture).
|
||||
try:
|
||||
tools = _probe_single_server(name, server_config)
|
||||
# A clean probe is NOT proof of authentication. Some MCP servers
|
||||
# (notably Google's official Drive server) serve initialize +
|
||||
# tools/list WITHOUT auth, so the probe lists tools even when the
|
||||
# OAuth flow never completed — e.g. dynamic client registration
|
||||
# 400'd because the provider doesn't support RFC 7591. Reporting
|
||||
# "Authenticated — N tools" in that case is a false success: every
|
||||
# real tool call later hangs until timeout because there's no token.
|
||||
# Verify a token actually landed on disk before claiming success.
|
||||
if not _oauth_tokens_present(name):
|
||||
_warning(
|
||||
"Server responded, but no OAuth token was obtained — "
|
||||
"authentication did not complete."
|
||||
)
|
||||
print()
|
||||
_info(
|
||||
"Some providers (e.g. Google Drive, Atlassian) do not support "
|
||||
"automatic client registration. For those you must create an "
|
||||
"OAuth client yourself and add its credentials to config.yaml:"
|
||||
)
|
||||
print()
|
||||
print(color(f" mcp_servers:", Colors.DIM))
|
||||
print(color(f" {name}:", Colors.DIM))
|
||||
print(color(f" url: {url}", Colors.DIM))
|
||||
print(color(f" auth: oauth", Colors.DIM))
|
||||
print(color(f" oauth:", Colors.DIM))
|
||||
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
|
||||
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
|
||||
print()
|
||||
_info("Then re-run `hermes mcp login " + name + "`.")
|
||||
return
|
||||
if tools:
|
||||
_success(f"Authenticated — {len(tools)} tool(s) available")
|
||||
else:
|
||||
|
||||
+51
-49
@@ -1145,12 +1145,8 @@ def list_authenticated_providers(
|
||||
if slug_norm != current_norm:
|
||||
return False
|
||||
try:
|
||||
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
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
return bool(has_aws_credentials())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -1332,12 +1328,10 @@ def list_authenticated_providers(
|
||||
# configured.
|
||||
if not has_creds and hermes_slug == "anthropic":
|
||||
try:
|
||||
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")
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
read_hermes_oauth_credentials,
|
||||
)
|
||||
hermes_creds = read_hermes_oauth_credentials()
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if (hermes_creds and hermes_creds.get("accessToken")) or \
|
||||
@@ -1562,24 +1556,21 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
# wire protocol are grouped into a single picker row, so e.g. four Ollama
|
||||
# entries pointing at ``http://localhost:11434/v1`` with per-model display
|
||||
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
# that differ only by suffix. Same-host entries with different ``key_env``
|
||||
# or ``api_mode`` remain distinct providers.
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
# Key by endpoint + credential identity + wire protocol instead of
|
||||
# slug: names frequently differ per model ("Ollama — X") while the
|
||||
# endpoint stays the same. Keep same-host providers with distinct
|
||||
# env-backed credentials or API protocols separate so picker selection
|
||||
# cannot route through the wrong credential/mode pair.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -1594,9 +1585,23 @@ def list_authenticated_providers(
|
||||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
continue
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
inline_api_key = (entry.get("api_key") or "").strip()
|
||||
key_env = (entry.get("key_env") or "").strip()
|
||||
api_key = inline_api_key or (
|
||||
os.environ.get(key_env, "").strip() if key_env else ""
|
||||
)
|
||||
api_mode = str(
|
||||
entry.get("api_mode")
|
||||
or entry.get("transport")
|
||||
or ""
|
||||
).strip().lower()
|
||||
credential_identity = (
|
||||
inline_api_key
|
||||
if inline_api_key
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
|
||||
group_key = (api_url, api_key)
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
@@ -1609,29 +1614,16 @@ def list_authenticated_providers(
|
||||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
}
|
||||
elif api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
@@ -1653,8 +1645,16 @@ def list_authenticated_providers(
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
|
||||
_current_base_url_group_count = sum(
|
||||
1
|
||||
for _grp in groups.values()
|
||||
if _current_base_url_norm
|
||||
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
|
||||
)
|
||||
for grp in groups.values():
|
||||
api_url = grp["api_url"]
|
||||
api_key = grp.get("api_key", "")
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
@@ -1727,8 +1727,10 @@ def list_authenticated_providers(
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
"is_current": slug == current_provider or (
|
||||
bool(current_base_url)
|
||||
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
|
||||
current_provider == "custom"
|
||||
and bool(_current_base_url_norm)
|
||||
and _grp_url_norm == _current_base_url_norm
|
||||
and _current_base_url_group_count == 1
|
||||
),
|
||||
"is_user_defined": True,
|
||||
"models": grp["models"],
|
||||
|
||||
+4
-19
@@ -2019,11 +2019,7 @@ 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.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")
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
ids = bedrock_model_ids_or_none()
|
||||
if ids is not None:
|
||||
return ids
|
||||
@@ -2270,14 +2266,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
Claude Code auto-discovery). Returns sorted model IDs or None.
|
||||
"""
|
||||
try:
|
||||
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")
|
||||
# Beta header constants live in core agent.anthropic_format.
|
||||
from agent.anthropic_format import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
if resolve_anthropic_token is None or _is_oauth_token is None:
|
||||
raise ImportError("anthropic provider services not registered")
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@@ -2289,6 +2278,7 @@ 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)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
@@ -3620,12 +3610,7 @@ def validate_requested_model(
|
||||
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
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")
|
||||
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
|
||||
region = resolve_bedrock_region()
|
||||
discovered = discover_bedrock_models(region)
|
||||
discovered_ids = {m["id"] for m in discovered}
|
||||
|
||||
@@ -71,12 +71,16 @@ class NousSubscriptionFeatures:
|
||||
def browser(self) -> NousFeatureState:
|
||||
return self.features["browser"]
|
||||
|
||||
@property
|
||||
def video_gen(self) -> NousFeatureState:
|
||||
return self.features["video_gen"]
|
||||
|
||||
@property
|
||||
def modal(self) -> NousFeatureState:
|
||||
return self.features["modal"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "tts", "browser", "modal")
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
@@ -255,6 +259,7 @@ def get_nous_subscription_features(
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
image_tool_enabled = _toolset_enabled(config, "image_gen")
|
||||
video_tool_enabled = _toolset_enabled(config, "video_gen")
|
||||
tts_tool_enabled = _toolset_enabled(config, "tts")
|
||||
browser_tool_enabled = _toolset_enabled(config, "browser")
|
||||
modal_tool_enabled = _toolset_enabled(config, "terminal")
|
||||
@@ -289,6 +294,8 @@ def get_nous_subscription_features(
|
||||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
|
||||
video_use_gateway = _uses_gateway(video_gen_cfg)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
@@ -296,6 +303,7 @@ def get_nous_subscription_features(
|
||||
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||
direct_searxng = bool(get_env_value("SEARXNG_URL"))
|
||||
direct_fal = fal_key_is_configured()
|
||||
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
|
||||
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
|
||||
@@ -311,6 +319,8 @@ def get_nous_subscription_features(
|
||||
direct_tavily = False
|
||||
if image_use_gateway:
|
||||
direct_fal = False
|
||||
if video_use_gateway:
|
||||
direct_fal_video = False
|
||||
if tts_use_gateway:
|
||||
direct_openai_tts = False
|
||||
direct_elevenlabs = False
|
||||
@@ -320,6 +330,8 @@ def get_nous_subscription_features(
|
||||
|
||||
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||
# Video gen uses the same fal-queue gateway as image gen.
|
||||
managed_video_available = managed_image_available
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
|
||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||
@@ -357,6 +369,10 @@ def get_nous_subscription_features(
|
||||
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
|
||||
image_available = bool(managed_image_available or direct_fal)
|
||||
|
||||
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
|
||||
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
|
||||
video_available = bool(managed_video_available or direct_fal_video)
|
||||
|
||||
tts_current_provider = tts_provider or "edge"
|
||||
tts_managed = (
|
||||
tts_tool_enabled
|
||||
@@ -451,6 +467,18 @@ def get_nous_subscription_features(
|
||||
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
|
||||
explicit_configured=direct_fal,
|
||||
),
|
||||
"video_gen": NousFeatureState(
|
||||
key="video_gen",
|
||||
label="Video generation",
|
||||
included_by_default=False,
|
||||
available=video_available,
|
||||
active=video_active,
|
||||
managed_by_nous=video_managed,
|
||||
direct_override=video_active and not video_managed,
|
||||
toolset_enabled=video_tool_enabled,
|
||||
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
|
||||
explicit_configured=direct_fal_video,
|
||||
),
|
||||
"tts": NousFeatureState(
|
||||
key="tts",
|
||||
label="OpenAI TTS",
|
||||
@@ -561,6 +589,9 @@ def apply_nous_managed_defaults(
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
@@ -571,6 +602,7 @@ def apply_nous_managed_defaults(
|
||||
_GATEWAY_TOOL_LABELS = {
|
||||
"web": "Web search & extract (Firecrawl)",
|
||||
"image_gen": "Image generation (FAL)",
|
||||
"video_gen": "Video generation (FAL)",
|
||||
"tts": "Text-to-speech (OpenAI TTS)",
|
||||
"browser": "Browser automation (Browser Use)",
|
||||
}
|
||||
@@ -578,6 +610,7 @@ _GATEWAY_TOOL_LABELS = {
|
||||
|
||||
def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
"""Return a dict of tool_key -> has_direct_credentials."""
|
||||
fal_direct = fal_key_is_configured()
|
||||
return {
|
||||
"web": bool(
|
||||
get_env_value("FIRECRAWL_API_KEY")
|
||||
@@ -586,7 +619,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
or get_env_value("TAVILY_API_KEY")
|
||||
or get_env_value("EXA_API_KEY")
|
||||
),
|
||||
"image_gen": fal_key_is_configured(),
|
||||
"image_gen": fal_direct,
|
||||
"video_gen": fal_direct,
|
||||
"tts": bool(
|
||||
resolve_openai_audio_api_key()
|
||||
or get_env_value("ELEVENLABS_API_KEY")
|
||||
@@ -601,11 +635,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
_GATEWAY_DIRECT_LABELS = {
|
||||
"web": "Firecrawl/Exa/Parallel/Tavily key",
|
||||
"image_gen": "FAL key",
|
||||
"video_gen": "FAL key",
|
||||
"tts": "OpenAI/ElevenLabs key",
|
||||
"browser": "Browser Use/Browserbase key",
|
||||
}
|
||||
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
|
||||
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
@@ -646,6 +681,7 @@ def get_gateway_eligible_tools(
|
||||
opted_in = {
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"video_gen": _uses_gateway(config.get("video_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
}
|
||||
@@ -714,6 +750,15 @@ def apply_gateway_defaults(
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in tool_keys:
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
|
||||
+25
-285
@@ -817,270 +817,6 @@ 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,
|
||||
)
|
||||
|
||||
def register_provider_resolver(
|
||||
self,
|
||||
name: str,
|
||||
resolver: Any,
|
||||
) -> None:
|
||||
"""Register a provider resolver callable.
|
||||
|
||||
The resolver handles ALL provider-specific client construction
|
||||
logic for auxiliary tasks. Core's ``resolve_provider_client()``
|
||||
dispatches to it instead of using per-provider if/elif branches.
|
||||
|
||||
Signature::
|
||||
|
||||
def resolver(
|
||||
*,
|
||||
model: str | None,
|
||||
explicit_api_key: str | None,
|
||||
explicit_base_url: str | None,
|
||||
async_mode: bool,
|
||||
is_vision: bool,
|
||||
main_runtime: dict | None,
|
||||
api_mode: str | None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
...
|
||||
|
||||
Returns ``(client, default_model)`` or ``(None, None)``.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_provider_resolver(name, resolver)
|
||||
logger.debug(
|
||||
"Plugin %s registered provider resolver: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
def register_transport(
|
||||
self,
|
||||
api_mode: str,
|
||||
transport_cls: type,
|
||||
) -> None:
|
||||
"""Register a ProviderTransport class for an api_mode string.
|
||||
|
||||
This lets the transport registry discover provider transports
|
||||
from plugins without core needing to import the plugin package.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries._transports[api_mode] = transport_cls
|
||||
logger.debug(
|
||||
"Plugin %s registered transport: %s → %s",
|
||||
self.manifest.name, api_mode, transport_cls.__name__,
|
||||
)
|
||||
|
||||
def register_credential_pool_hook(
|
||||
self,
|
||||
name: str,
|
||||
hook: Any,
|
||||
) -> None:
|
||||
"""Register a credential pool hook for provider-specific pool operations.
|
||||
|
||||
The hook should be a :class:`agent.plugin_registries.CredentialPoolHook`
|
||||
instance with optional ``sync_from_credentials_file``,
|
||||
``refresh_oauth``, and ``should_include_in_pool`` callables.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_credential_pool_hook(name, hook)
|
||||
logger.debug(
|
||||
"Plugin %s registered credential pool hook: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
def register_pricing_provider(
|
||||
self,
|
||||
name: str,
|
||||
entries: list,
|
||||
) -> None:
|
||||
"""Register pricing entries for a provider.
|
||||
|
||||
``entries`` should be a list of
|
||||
:class:`agent.plugin_registries.PricingEntry` instances.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_pricing_provider(name, entries)
|
||||
logger.debug(
|
||||
"Plugin %s registered pricing provider: %s (%d entries)",
|
||||
self.manifest.name, name, len(entries),
|
||||
)
|
||||
|
||||
def register_provider_overlay(
|
||||
self,
|
||||
entry: Any,
|
||||
) -> None:
|
||||
"""Register a provider overlay entry.
|
||||
|
||||
``entry`` should be a :class:`agent.plugin_registries.ProviderOverlayEntry`
|
||||
instance.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_provider_overlay(entry)
|
||||
logger.debug(
|
||||
"Plugin %s registered provider overlay: %s",
|
||||
self.manifest.name, entry.provider_name,
|
||||
)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
# -- auxiliary task registration ---------------------------------------
|
||||
@@ -1337,11 +1073,6 @@ 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"
|
||||
@@ -1378,16 +1109,7 @@ class PluginManager:
|
||||
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
|
||||
winners: Dict[str, PluginManifest] = {}
|
||||
for manifest in manifests:
|
||||
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
|
||||
winners[manifest.key or manifest.name] = manifest
|
||||
for manifest in winners.values():
|
||||
lookup_key = manifest.key or manifest.name
|
||||
|
||||
@@ -1415,12 +1137,30 @@ class PluginManager:
|
||||
)
|
||||
continue
|
||||
|
||||
# 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"}:
|
||||
# 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"}:
|
||||
self._load_plugin(manifest)
|
||||
continue
|
||||
|
||||
|
||||
+51
-23
@@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
|
||||
# Check existing commands in PATH
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
is_windows = sys.platform == "win32"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", canon], capture_output=True, text=True, timeout=5,
|
||||
["where" if is_windows else "which", canon],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
existing_path = result.stdout.strip()
|
||||
existing_path = result.stdout.strip().splitlines()[0]
|
||||
# Allow overwriting our own wrappers
|
||||
if existing_path == str(wrapper_dir / canon):
|
||||
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
|
||||
if existing_path == str(expected):
|
||||
try:
|
||||
content = (wrapper_dir / canon).read_text()
|
||||
content = expected.read_text()
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
@@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
alias name point at a differently-named profile without a post-hoc rewrite.
|
||||
|
||||
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
|
||||
Returns the path to the created wrapper, or None if creation failed.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
profile = normalize_profile_name(target) if target else canon
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
is_windows = sys.platform == "win32"
|
||||
if is_windows:
|
||||
wrapper_path = wrapper_dir / f"{canon}.bat"
|
||||
try:
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
else:
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+17
-40
@@ -99,8 +99,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
|
||||
),
|
||||
# "anthropic" overlay moved to plugin: hermes_agent_anthropic register()
|
||||
# Plugin registers via ctx.register_provider_overlay() and core merges lazily.
|
||||
"anthropic": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
),
|
||||
"zai": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
||||
@@ -202,45 +204,17 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
),
|
||||
# Azure Foundry: supports both OpenAI-style and Anthropic-style endpoints.
|
||||
# The transport is determined at runtime from config.yaml model.api_mode.
|
||||
# "azure-foundry" overlay moved to plugin: hermes_agent_azure register()
|
||||
# "bedrock" overlay moved to plugin: hermes_agent_bedrock register()
|
||||
# Plugins register via ctx.register_provider_overlay() and core merges lazily.
|
||||
"azure-foundry": HermesOverlay(
|
||||
transport="openai_chat", # default; overridden by api_mode in config
|
||||
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
|
||||
),
|
||||
"bedrock": HermesOverlay(
|
||||
transport="bedrock_converse",
|
||||
auth_type="aws_sdk",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _merge_plugin_overlays() -> None:
|
||||
"""Merge plugin-registered provider overlays into HERMES_OVERLAYS.
|
||||
|
||||
Called lazily from ``resolve_provider`` so that plugins have had a
|
||||
chance to register by the time we need the overlay data.
|
||||
"""
|
||||
global _plugin_overlays_merged
|
||||
if _plugin_overlays_merged:
|
||||
return
|
||||
_plugin_overlays_merged = True
|
||||
try:
|
||||
from agent.plugin_registries import registries
|
||||
for _name, _entry in registries.all_provider_overlays().items():
|
||||
if _name not in HERMES_OVERLAYS:
|
||||
HERMES_OVERLAYS[_name] = HermesOverlay(
|
||||
transport=_entry.transport,
|
||||
is_aggregator=_entry.is_aggregator,
|
||||
auth_type=_entry.auth_type,
|
||||
extra_env_vars=_entry.extra_env_vars,
|
||||
base_url_override=_entry.base_url_override,
|
||||
base_url_env_var=_entry.base_url_env_var,
|
||||
)
|
||||
# Also merge aliases from the plugin overlay entry
|
||||
for _alias in _entry.aliases:
|
||||
if _alias not in ALIASES:
|
||||
ALIASES[_alias] = _name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_plugin_overlays_merged = False
|
||||
|
||||
|
||||
# -- Resolved provider -------------------------------------------------------
|
||||
# The merged result of models.dev + overlay + user config.
|
||||
|
||||
@@ -361,7 +335,11 @@ ALIASES: Dict[str, str] = {
|
||||
"tencent-cloud": "tencent-tokenhub",
|
||||
"tencentmaas": "tencent-tokenhub",
|
||||
|
||||
# bedrock aliases moved to plugin: hermes_agent_bedrock register()
|
||||
# bedrock
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"amazon-bedrock": "bedrock",
|
||||
"amazon": "bedrock",
|
||||
|
||||
# arcee
|
||||
"arcee-ai": "arcee",
|
||||
@@ -448,7 +426,6 @@ def get_provider(name: str) -> Optional[ProviderDef]:
|
||||
except Exception:
|
||||
mdev_info = None
|
||||
|
||||
_merge_plugin_overlays()
|
||||
overlay = HERMES_OVERLAYS.get(canonical)
|
||||
|
||||
if mdev_info is not None:
|
||||
|
||||
@@ -976,13 +976,11 @@ def _resolve_azure_foundry_runtime(
|
||||
auth_mode = "api_key"
|
||||
else:
|
||||
try:
|
||||
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")
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
build_token_provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AuthError(
|
||||
"Azure Foundry Entra ID auth requires the 'azure-identity' "
|
||||
@@ -1074,11 +1072,7 @@ 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.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")
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
|
||||
api_key = resolve_anthropic_token()
|
||||
if not api_key:
|
||||
@@ -1522,11 +1516,7 @@ def resolve_runtime_provider(
|
||||
"config.yaml model section at a custom env var."
|
||||
)
|
||||
else:
|
||||
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")
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
token = resolve_anthropic_token()
|
||||
if not token:
|
||||
raise AuthError(
|
||||
@@ -1544,14 +1534,12 @@ def resolve_runtime_provider(
|
||||
|
||||
# AWS Bedrock (native Converse API via boto3)
|
||||
if provider == "bedrock":
|
||||
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")
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
is_anthropic_bedrock_model,
|
||||
)
|
||||
# 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
|
||||
|
||||
@@ -104,7 +104,9 @@ ADVISORIES: tuple[Advisory, ...] = (
|
||||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed."
|
||||
"are exposed. PyPI has since removed 2.4.6 and the project ships "
|
||||
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
|
||||
"the compromised 2.4.6 is still installed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
|
||||
+70
-40
@@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
# Video generation — opt-in via `hermes tools` → Video Generation.
|
||||
# Only show the row when a plugin reports available so we don't badger
|
||||
# users who don't care about video gen with a "missing" status line.
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
if subscription_features.video_gen.managed_by_nous:
|
||||
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
|
||||
else:
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
|
||||
# TTS — show configured provider
|
||||
tts_provider = cfg_get(config, "tts", "provider", default="edge")
|
||||
@@ -2050,32 +2053,59 @@ def _setup_matrix():
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
matrix_pkg = "hermes-agent[matrix]"
|
||||
# Matrix deps are now a proper plugin package. Install it the normal way.
|
||||
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).
|
||||
try:
|
||||
__import__("hermes_agent_matrix")
|
||||
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}")
|
||||
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 '{matrix_pkg}'"
|
||||
)
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
# 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()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
|
||||
+47
-16
@@ -244,9 +244,16 @@ TOOL_CATEGORIES = {
|
||||
],
|
||||
"tts_provider": "elevenlabs",
|
||||
},
|
||||
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
|
||||
# package is currently quarantined (malicious 2.4.6 release on
|
||||
# 2026-05-12). Restore this entry once PyPI un-quarantines.
|
||||
# Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use.
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
"tts_provider": "mistral",
|
||||
},
|
||||
{
|
||||
"name": "Google Gemini TTS",
|
||||
"badge": "preview",
|
||||
@@ -339,11 +346,26 @@ TOOL_CATEGORIES = {
|
||||
"video_gen": {
|
||||
"name": "Video Generation",
|
||||
"icon": "🎬",
|
||||
# Providers list is intentionally empty — every video gen backend
|
||||
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
|
||||
# injected by ``_visible_providers``. Mirrors the design we'll
|
||||
# converge image_gen toward.
|
||||
"providers": [],
|
||||
# "Nous Subscription" row mirrors the image_gen pattern — managed
|
||||
# FAL video generation billed via the Nous Portal. Plugin-backed
|
||||
# provider rows (FAL BYOK, xAI, …) are injected at runtime by
|
||||
# ``_plugin_video_gen_providers()`` in ``_visible_providers``.
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed FAL video generation billed to your subscription",
|
||||
"env_vars": [],
|
||||
"requires_nous_auth": True,
|
||||
"managed_nous_feature": "video_gen",
|
||||
"override_env_vars": ["FAL_KEY"],
|
||||
# The underlying plugin backend — when the user picks
|
||||
# "Nous Subscription" we set video_gen.provider = "fal"
|
||||
# and video_gen.use_gateway = True so the FAL plugin
|
||||
# routes through the managed queue gateway.
|
||||
"video_gen_plugin_name": "fal",
|
||||
},
|
||||
],
|
||||
},
|
||||
"x_search": {
|
||||
"name": "X (Twitter) Search",
|
||||
@@ -1438,7 +1460,7 @@ def _toolset_has_keys(
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if ts_key in {"web", "image_gen", "tts", "browser"}:
|
||||
if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}:
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
feature = features.features.get(ts_key)
|
||||
if feature and (feature.available or feature.managed_by_nous):
|
||||
@@ -2153,7 +2175,7 @@ def _is_provider_active(
|
||||
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
|
||||
|
||||
video_plugin_name = provider.get("video_gen_plugin_name")
|
||||
if video_plugin_name:
|
||||
if video_plugin_name and not provider.get("managed_nous_feature"):
|
||||
video_cfg = config.get("video_gen", {})
|
||||
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
|
||||
|
||||
@@ -2172,6 +2194,15 @@ def _is_provider_active(
|
||||
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if managed_feature == "video_gen":
|
||||
video_cfg = config.get("video_gen", {})
|
||||
if isinstance(video_cfg, dict):
|
||||
configured_provider = video_cfg.get("provider")
|
||||
if configured_provider not in {None, "", "fal"}:
|
||||
return False
|
||||
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
feature.managed_by_nous
|
||||
@@ -2505,14 +2536,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None
|
||||
_print_success(f" Model set to: {chosen}")
|
||||
|
||||
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
|
||||
"""Persist a plugin-backed video generation provider selection."""
|
||||
vid_cfg = config.setdefault("video_gen", {})
|
||||
if not isinstance(vid_cfg, dict):
|
||||
vid_cfg = {}
|
||||
config["video_gen"] = vid_cfg
|
||||
vid_cfg["provider"] = plugin_name
|
||||
vid_cfg["use_gateway"] = False
|
||||
vid_cfg["use_gateway"] = use_gateway
|
||||
_print_success(f" video_gen.provider set to: {plugin_name}")
|
||||
_configure_videogen_model_for_plugin(plugin_name, config)
|
||||
|
||||
@@ -2597,7 +2628,7 @@ def _configure_provider(
|
||||
# registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection after backend pick.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2676,7 +2707,7 @@ def _configure_provider(
|
||||
return
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection after env vars are in.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2957,7 +2988,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection on reconfig too.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2997,7 +3028,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
|
||||
backend = provider.get("imagegen_backend")
|
||||
|
||||
+1
-5
@@ -779,9 +779,7 @@ def speak_text(text: str) -> None:
|
||||
_debug(f"speak_text: TTS begin (paused_recording={paused_recording})")
|
||||
|
||||
try:
|
||||
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
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
tts_text = text[:4000] if len(text) > 4000 else text
|
||||
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
|
||||
@@ -808,8 +806,6 @@ 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)
|
||||
|
||||
|
||||
+33
-37
@@ -58,10 +58,22 @@ try:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
"Install with: pip install 'hermes-agent[dashboard]'"
|
||||
)
|
||||
# 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]'"
|
||||
)
|
||||
|
||||
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__)
|
||||
@@ -308,9 +320,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "openai"],
|
||||
"options": ["local", "openai", "mistral"],
|
||||
},
|
||||
"display.skin": {
|
||||
"type": "select",
|
||||
@@ -1362,13 +1372,11 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
|
||||
The dashboard reports the highest-priority source that's actually present.
|
||||
"""
|
||||
try:
|
||||
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")
|
||||
from agent.anthropic_adapter import (
|
||||
read_hermes_oauth_credentials,
|
||||
read_claude_code_credentials,
|
||||
_HERMES_OAUTH_FILE,
|
||||
)
|
||||
except ImportError:
|
||||
read_claude_code_credentials = None # type: ignore
|
||||
read_hermes_oauth_credentials = None # type: ignore
|
||||
@@ -1427,11 +1435,7 @@ def _claude_code_only_status() -> Dict[str, Any]:
|
||||
when they also have a separate Hermes-managed PKCE login.
|
||||
"""
|
||||
try:
|
||||
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")
|
||||
from agent.anthropic_adapter import read_claude_code_credentials
|
||||
creds = read_claude_code_credentials()
|
||||
except Exception:
|
||||
creds = None
|
||||
@@ -1617,10 +1621,8 @@ 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.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():
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
_HERMES_OAUTH_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1687,15 +1689,13 @@ _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.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")
|
||||
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,
|
||||
)
|
||||
_ANTHROPIC_OAUTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
_ANTHROPIC_OAUTH_AVAILABLE = False
|
||||
@@ -1733,11 +1733,7 @@ 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.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")
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
payload = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
|
||||
+2
-9
@@ -143,15 +143,8 @@ def create_environment(
|
||||
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
|
||||
|
||||
elif env_type == "modal":
|
||||
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)
|
||||
from tools.environments.modal import ModalEnvironment
|
||||
return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")
|
||||
|
||||
-233
@@ -260,239 +260,6 @@ 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
|
||||
'';
|
||||
|
||||
# Regression guard: messaging deps live outside [all], so the
|
||||
# #messaging variant must actually ship discord.py — otherwise
|
||||
# `nix profile install .#messaging` regresses to the broken default.
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/workspace";
|
||||
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
description = "Working directory for the agent (MESSAGING_CWD).";
|
||||
description = "Working directory for the agent.";
|
||||
};
|
||||
|
||||
# ── Declarative config ───────────────────────────────────────────────
|
||||
|
||||
+2
-6
@@ -19,10 +19,8 @@
|
||||
# Ships discord.py + python-telegram-bot + slack-sdk so a plain
|
||||
# `nix profile install .#messaging` connects to Discord/Telegram/Slack
|
||||
# on first run — lazy-install can't write to the read-only /nix/store.
|
||||
# (Pluginify: the old `messaging` extra is now per-platform workspace
|
||||
# members, so this aggregates discord/telegram/slack explicitly.)
|
||||
messaging = hermesAgent.override {
|
||||
extraDependencyGroups = [ "discord" "telegram" "slack" ];
|
||||
extraDependencyGroups = [ "messaging" ];
|
||||
};
|
||||
|
||||
# All platform-portable optional integrations pre-built.
|
||||
@@ -34,7 +32,6 @@
|
||||
"bedrock"
|
||||
"daytona"
|
||||
"dingtalk"
|
||||
"discord"
|
||||
"edge-tts"
|
||||
"exa"
|
||||
"fal"
|
||||
@@ -42,10 +39,9 @@
|
||||
"firecrawl"
|
||||
"hindsight"
|
||||
"honcho"
|
||||
"messaging"
|
||||
"modal"
|
||||
"parallel-web"
|
||||
"slack"
|
||||
"telegram"
|
||||
"tts-premium"
|
||||
"voice"
|
||||
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
|
||||
@@ -38,7 +38,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
|
||||
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
|
||||
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
|
||||
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
|
||||
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
|
||||
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
|
||||
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
|
||||
|
||||
@@ -26,7 +26,7 @@ Optional feature knobs::
|
||||
BROWSERBASE_PROXIES=true # default true
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
BROWSERBASE_KEEP_ALIVE=true # default true
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (ms, integer)
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Hermes Agent web dashboard."""
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
|
||||
pass
|
||||
@@ -1,6 +0,0 @@
|
||||
name: dashboard
|
||||
version: 0.1.0
|
||||
description: Web dashboard (FastAPI + uvicorn)
|
||||
kind: backend
|
||||
provides_tools: ["dashboard"]
|
||||
provides_hooks: []
|
||||
@@ -1,20 +0,0 @@
|
||||
[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*"]
|
||||
@@ -481,7 +481,14 @@ def guess_category(path: Path) -> Optional[str]:
|
||||
}:
|
||||
return None
|
||||
if top == "cron" or top == "cronjobs":
|
||||
return "cron-output"
|
||||
# Only files under the disposable ``output/`` subtree are
|
||||
# cleanup candidates. Top-level cron control-plane state
|
||||
# (e.g. ``jobs.json``, ``.tick.lock``) must never be
|
||||
# auto-tracked — deleting it wipes the live scheduler
|
||||
# registry. See issue #32164.
|
||||
if len(rel.parts) >= 2 and rel.parts[1] == "output":
|
||||
return "cron-output"
|
||||
return None
|
||||
if top == "cache":
|
||||
return "temp"
|
||||
except ValueError:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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,
|
||||
},
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
name: fal
|
||||
version: 0.1.0
|
||||
description: FAL.ai image generation backend
|
||||
kind: backend
|
||||
provides_tools: ["image_gen"]
|
||||
provides_hooks: []
|
||||
@@ -1,19 +0,0 @@
|
||||
[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*"]
|
||||
@@ -888,7 +888,8 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
try:
|
||||
from hindsight import HindsightEmbedded # noqa: F401 — side-effect import
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.hindsight", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as _e:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[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*"]
|
||||
@@ -745,12 +745,23 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
"For local instances, set HONCHO_BASE_URL instead."
|
||||
)
|
||||
|
||||
# Import the honcho SDK (installed via hermes-agent-honcho package).
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# 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 honcho import Honcho # noqa: F401 — imported for side-effects
|
||||
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
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[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*"]
|
||||
@@ -19,9 +19,3 @@ alibaba_coding_plan = ProviderProfile(
|
||||
)
|
||||
|
||||
register_provider(alibaba_coding_plan)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -11,9 +11,3 @@ alibaba = ProviderProfile(
|
||||
)
|
||||
|
||||
register_provider(alibaba)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -50,9 +50,3 @@ 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)
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
"""hermes-agent-anthropic: Anthropic Messages API adapter for Hermes Agent."""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Re-exports from adapter.py — SDK-dependent orchestration only.
|
||||
# Wire-format code (message conversion, aux client wrappers, transport)
|
||||
# has moved to core and is no longer re-exported here.
|
||||
# -----------------------------------------------------------------------
|
||||
from hermes_agent_anthropic.adapter import ( # noqa: F401
|
||||
_CLAUDE_CODE_VERSION_FALLBACK,
|
||||
_HERMES_OAUTH_FILE,
|
||||
_OAUTH_CLIENT_ID,
|
||||
_OAUTH_REDIRECT_URI,
|
||||
_OAUTH_SCOPES,
|
||||
_OAUTH_TOKEN_URL,
|
||||
_build_anthropic_client_with_bearer_hook,
|
||||
_detect_claude_code_version,
|
||||
_generate_pkce,
|
||||
_get_anthropic_sdk,
|
||||
_get_claude_code_version,
|
||||
_is_azure_anthropic_endpoint,
|
||||
_is_oauth_token,
|
||||
_prefer_refreshable_claude_code_token,
|
||||
_read_claude_code_credentials_from_keychain,
|
||||
_refresh_oauth_token,
|
||||
_requires_bearer_auth,
|
||||
_resolve_claude_code_token_from_credentials,
|
||||
_write_claude_code_credentials,
|
||||
build_anthropic_bedrock_client,
|
||||
build_anthropic_client,
|
||||
is_claude_code_token_valid,
|
||||
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,
|
||||
)
|
||||
|
||||
# Re-exports from resolve.py — client resolution & endpoint detection
|
||||
from hermes_agent_anthropic.resolve import ( # noqa: F401
|
||||
_ANTHROPIC_DEFAULT_BASE_URL as ANTHROPIC_DEFAULT_BASE_URL,
|
||||
convert_openai_images_to_anthropic,
|
||||
endpoint_speaks_anthropic_messages,
|
||||
is_anthropic_compat_endpoint,
|
||||
maybe_wrap_anthropic,
|
||||
resolve_auxiliary_client,
|
||||
)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Entry point for the hermes_agent.plugins entry point group."""
|
||||
from hermes_agent_anthropic import adapter
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Plugin-only symbols — SDK-dependent orchestration that stays in the
|
||||
# plugin package. Wire-format code (message conversion, aux client
|
||||
# wrappers, transport) has moved to core (agent.anthropic_format,
|
||||
# agent.anthropic_aux, agent.transports.anthropic) and is no longer
|
||||
# registered here.
|
||||
# -----------------------------------------------------------------------
|
||||
_symbols = [
|
||||
# OAuth / auth constants
|
||||
"_CLAUDE_CODE_VERSION_FALLBACK",
|
||||
"_HERMES_OAUTH_FILE",
|
||||
"_OAUTH_CLIENT_ID",
|
||||
"_OAUTH_REDIRECT_URI",
|
||||
"_OAUTH_SCOPES",
|
||||
"_OAUTH_TOKEN_URL",
|
||||
# SDK-dependent functions
|
||||
"_build_anthropic_client_with_bearer_hook",
|
||||
"_detect_claude_code_version",
|
||||
"_generate_pkce",
|
||||
"_get_anthropic_sdk",
|
||||
"_get_claude_code_version",
|
||||
"_is_azure_anthropic_endpoint",
|
||||
"_is_oauth_token",
|
||||
"_prefer_refreshable_claude_code_token",
|
||||
"_read_claude_code_credentials_from_keychain",
|
||||
"_refresh_oauth_token",
|
||||
"_requires_bearer_auth",
|
||||
"_resolve_claude_code_token_from_credentials",
|
||||
"_write_claude_code_credentials",
|
||||
"build_anthropic_bedrock_client",
|
||||
"build_anthropic_client",
|
||||
"is_claude_code_token_valid",
|
||||
"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",
|
||||
]
|
||||
|
||||
# resolve.py symbols — client resolution & endpoint detection
|
||||
_resolve_symbols = [
|
||||
"_ANTHROPIC_DEFAULT_BASE_URL",
|
||||
"_ANTHROPIC_COMPAT_PROVIDERS",
|
||||
"convert_openai_images_to_anthropic",
|
||||
"endpoint_speaks_anthropic_messages",
|
||||
"is_anthropic_compat_endpoint",
|
||||
"maybe_wrap_anthropic",
|
||||
"resolve_auxiliary_client",
|
||||
]
|
||||
_all_symbols = _symbols + _resolve_symbols
|
||||
_services = {}
|
||||
for name in _symbols:
|
||||
_services[name] = getattr(adapter, name)
|
||||
for name in _resolve_symbols:
|
||||
from hermes_agent_anthropic import resolve as _resolve_mod
|
||||
_services[name] = getattr(_resolve_mod, name)
|
||||
# Also expose ANTHROPIC_DEFAULT_BASE_URL under the public (no-underscore) name
|
||||
_services["ANTHROPIC_DEFAULT_BASE_URL"] = _services.get("_ANTHROPIC_DEFAULT_BASE_URL", "")
|
||||
|
||||
# Also expose the model name normalizer as a provider service
|
||||
from hermes_agent_anthropic.pricing import normalize_anthropic_model_name
|
||||
_services["normalize_model_name"] = normalize_anthropic_model_name
|
||||
|
||||
ctx.register_provider_services("anthropic", _services)
|
||||
|
||||
# Register the provider resolver — core dispatches to this instead of
|
||||
# having per-anthropic if/elif branches in resolve_provider_client().
|
||||
ctx.register_provider_resolver("anthropic", resolve_auxiliary_client)
|
||||
|
||||
# Register the anthropic transport so core doesn't need to import it.
|
||||
from agent.transports.anthropic import AnthropicTransport
|
||||
ctx.register_transport("anthropic_messages", AnthropicTransport)
|
||||
|
||||
# Register the credential pool hook — core dispatches to this instead of
|
||||
# having per-anthropic if/elif branches in credential_pool.py.
|
||||
from agent.plugin_registries import CredentialPoolHook
|
||||
from hermes_agent_anthropic.credential_pool_hook import (
|
||||
sync_from_credentials_file,
|
||||
refresh_oauth,
|
||||
needs_refresh,
|
||||
should_include_in_pool,
|
||||
source_priority,
|
||||
discover_credentials,
|
||||
ANTHROPIC_ENV_VAR_ORDER,
|
||||
detect_auth_type,
|
||||
)
|
||||
ctx.register_credential_pool_hook("anthropic", CredentialPoolHook(
|
||||
sync_from_credentials_file=sync_from_credentials_file,
|
||||
refresh_oauth=refresh_oauth,
|
||||
needs_refresh=needs_refresh,
|
||||
should_include_in_pool=should_include_in_pool,
|
||||
source_priority=source_priority,
|
||||
discover_credentials=discover_credentials,
|
||||
env_var_order=ANTHROPIC_ENV_VAR_ORDER,
|
||||
detect_auth_type=detect_auth_type,
|
||||
))
|
||||
|
||||
# Register pricing entries — core looks these up via the registry
|
||||
# instead of hardcoding them in _OFFICIAL_DOCS_PRICING.
|
||||
from hermes_agent_anthropic.pricing import (
|
||||
get_anthropic_pricing_entries,
|
||||
ANTHROPIC_PRICING_KEYS,
|
||||
)
|
||||
_entries = get_anthropic_pricing_entries()
|
||||
_keyed = []
|
||||
for (prov, model), entry in zip(ANTHROPIC_PRICING_KEYS, _entries):
|
||||
_keyed.append((prov, model, entry))
|
||||
ctx.register_pricing_provider("anthropic", _keyed)
|
||||
|
||||
# Register the provider overlay — core merges this into HERMES_OVERLAYS
|
||||
from agent.plugin_registries import ProviderOverlayEntry
|
||||
ctx.register_provider_overlay(ProviderOverlayEntry(
|
||||
provider_name="anthropic",
|
||||
transport="anthropic_messages",
|
||||
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
display_name="Anthropic",
|
||||
aliases=[],
|
||||
))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
"""Anthropic credential pool hook.
|
||||
|
||||
Handles provider-specific pool operations: syncing from ~/.claude/.credentials.json,
|
||||
refreshing OAuth tokens, and deciding which sources to include in the pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sync_from_credentials_file(entry: Any) -> Any:
|
||||
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
|
||||
|
||||
OAuth refresh tokens are single-use. When something external (e.g.
|
||||
Claude Code CLI, or another profile's pool) refreshes the token, it
|
||||
writes the new pair to ~/.claude/.credentials.json. The pool entry's
|
||||
refresh token becomes stale. This method detects that and syncs.
|
||||
|
||||
Returns the (possibly updated) entry.
|
||||
"""
|
||||
if entry.source != "claude_code":
|
||||
return entry
|
||||
try:
|
||||
from agent.plugin_registries import registries
|
||||
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
|
||||
if read_claude_code_credentials is None:
|
||||
return entry
|
||||
creds = read_claude_code_credentials()
|
||||
if not creds:
|
||||
return entry
|
||||
file_refresh = creds.get("refreshToken", "")
|
||||
file_access = creds.get("accessToken", "")
|
||||
file_expires = creds.get("expiresAt", 0)
|
||||
if file_refresh and file_refresh != entry.refresh_token:
|
||||
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
|
||||
return replace(
|
||||
entry,
|
||||
access_token=file_access,
|
||||
refresh_token=file_refresh,
|
||||
expires_at_ms=file_expires,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync from credentials file: %s", exc)
|
||||
return entry
|
||||
|
||||
|
||||
def refresh_oauth(entry: Any, pool: Any) -> Any:
|
||||
"""Refresh an anthropic OAuth token and return the updated entry.
|
||||
|
||||
Handles:
|
||||
- Standard OAuth refresh via ``refresh_anthropic_oauth_pure``
|
||||
- Writing back to ~/.claude/.credentials.json for claude_code entries
|
||||
- Retry with synced token from credentials file on refresh failure
|
||||
|
||||
Returns the updated entry, or the original entry on failure.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
|
||||
if refresh_anthropic_oauth_pure is None:
|
||||
return entry
|
||||
|
||||
try:
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
entry.refresh_token,
|
||||
use_json=entry.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
)
|
||||
# Keep ~/.claude/.credentials.json in sync
|
||||
if entry.source == "claude_code":
|
||||
try:
|
||||
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
|
||||
if _write_claude_code_credentials is not None:
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||
return updated
|
||||
except Exception as exc:
|
||||
logger.debug("Credential refresh failed for anthropic/%s: %s", entry.id, exc)
|
||||
# The refresh token may have been consumed by another process.
|
||||
# Check if ~/.claude/.credentials.json has a newer token pair.
|
||||
if entry.source == "claude_code":
|
||||
synced = sync_from_credentials_file(entry)
|
||||
if synced.refresh_token != entry.refresh_token:
|
||||
logger.debug("Retrying refresh with synced token from credentials file")
|
||||
try:
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
synced.refresh_token,
|
||||
use_json=synced.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
synced,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
last_status="OK",
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
try:
|
||||
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
|
||||
if _write_claude_code_credentials is not None:
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return updated
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
|
||||
def needs_refresh(entry: Any) -> bool:
|
||||
"""Check if an anthropic OAuth entry needs a token refresh."""
|
||||
if entry.expires_at_ms is None:
|
||||
return False
|
||||
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
|
||||
|
||||
|
||||
def should_include_in_pool(source: str) -> bool:
|
||||
"""Which anthropic credential sources should be pooled."""
|
||||
return source in {"claude_code", "hermes_pkce"}
|
||||
|
||||
|
||||
def source_priority(source: str) -> int:
|
||||
"""Priority ordering for anthropic credential sources (lower = preferred)."""
|
||||
_PRIORITIES = {
|
||||
"claude_code": 3,
|
||||
"hermes_pkce": 2,
|
||||
}
|
||||
return _PRIORITIES.get(source, 99)
|
||||
|
||||
|
||||
def discover_credentials(entries: list, provider: str, is_suppressed: Any) -> tuple:
|
||||
"""Discover external anthropic credentials and upsert into pool entries.
|
||||
|
||||
Returns (changed: bool, active_sources: set).
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
changed = False
|
||||
active_sources = set()
|
||||
|
||||
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
|
||||
# when the user has explicitly configured anthropic as their provider.
|
||||
# Without this gate, auxiliary client fallback chains silently read
|
||||
# ~/.claude/.credentials.json without user consent. See PR #4210.
|
||||
try:
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
if not is_provider_explicitly_configured("anthropic"):
|
||||
return changed, active_sources
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# API-key vs OAuth is a user-visible choice at `hermes setup` ("Claude
|
||||
# Pro/Max subscription" vs "Anthropic API key"). The signal that the
|
||||
# user picked the API-key path is: ANTHROPIC_API_KEY set in the env,
|
||||
# AND no OAuth env vars set — `save_anthropic_api_key()` writes the
|
||||
# API key and zeros ANTHROPIC_TOKEN; `save_anthropic_oauth_token()`
|
||||
# does the inverse. When that signal is present we MUST NOT seed
|
||||
# autodiscovered OAuth tokens (~/.claude/.credentials.json from the
|
||||
# Claude Code CLI, hermes_pkce creds from a previous OAuth login)
|
||||
# into the anthropic pool — otherwise rotation on a 401/429 silently
|
||||
# flips the session onto an OAuth credential, which forces the Claude
|
||||
# Code identity injection, `mcp_` tool-name rewrite, and claude-cli
|
||||
# User-Agent header. Users who explicitly opted into the API-key path
|
||||
# are explicitly opting OUT of that masquerade. Prefer ~/.hermes/.env
|
||||
# over os.environ for the same reason `_seed_from_env` does — that's
|
||||
# the authoritative file that `hermes setup` writes.
|
||||
try:
|
||||
from hermes_cli.config import load_env
|
||||
except ImportError:
|
||||
load_env = None # type: ignore[assignment]
|
||||
|
||||
_env_file = load_env() if load_env is not None else {}
|
||||
|
||||
def _env_val(key: str) -> str:
|
||||
return (_env_file.get(key) or os.environ.get(key) or "").strip()
|
||||
|
||||
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
|
||||
anthropic_oauth_env = (
|
||||
_env_val("ANTHROPIC_TOKEN") or _env_val("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
)
|
||||
api_key_path_explicit = bool(anthropic_api_key and not anthropic_oauth_env)
|
||||
|
||||
if api_key_path_explicit:
|
||||
# Prune any stale autodiscovered OAuth entries that may have been
|
||||
# seeded into the on-disk pool during a previous OAuth session.
|
||||
# Without this, switching OAuth -> API key at setup leaves the
|
||||
# OAuth entries dormant in auth.json forever and rotation on a
|
||||
# transient 401 could revive them.
|
||||
retained = [
|
||||
entry for entry in entries
|
||||
if entry.source not in {"hermes_pkce", "claude_code"}
|
||||
]
|
||||
if len(retained) != len(entries):
|
||||
entries[:] = retained
|
||||
changed = True
|
||||
return changed, active_sources
|
||||
|
||||
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")
|
||||
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
|
||||
return changed, active_sources
|
||||
|
||||
# Import pool helpers
|
||||
try:
|
||||
from agent.credential_pool import _upsert_entry, label_from_token, AUTH_TYPE_OAUTH
|
||||
except ImportError:
|
||||
return changed, active_sources
|
||||
|
||||
for source_name, creds in (
|
||||
("hermes_pkce", read_hermes_oauth_credentials()),
|
||||
("claude_code", read_claude_code_credentials()),
|
||||
):
|
||||
if creds and creds.get("accessToken"):
|
||||
if is_suppressed(provider, source_name):
|
||||
continue
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": creds.get("accessToken", ""),
|
||||
"refresh_token": creds.get("refreshToken"),
|
||||
"expires_at_ms": creds.get("expiresAt"),
|
||||
"label": label_from_token(creds.get("accessToken", ""), source_name),
|
||||
},
|
||||
)
|
||||
return changed, active_sources
|
||||
|
||||
|
||||
# Env var scan order for anthropic — prefer OAuth tokens over API keys
|
||||
ANTHROPIC_ENV_VAR_ORDER = [
|
||||
"ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]
|
||||
|
||||
|
||||
def detect_auth_type(token: str) -> str:
|
||||
"""Determine auth type for an anthropic token.
|
||||
|
||||
OAuth tokens don't start with 'sk-ant-api'; API keys do.
|
||||
"""
|
||||
from agent.credential_pool import AUTH_TYPE_OAUTH, AUTH_TYPE_API_KEY
|
||||
if not token.startswith("sk-ant-api"):
|
||||
return AUTH_TYPE_OAUTH
|
||||
return AUTH_TYPE_API_KEY
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Anthropic model pricing data.
|
||||
|
||||
Official docs snapshot entries for Anthropic Claude models.
|
||||
Source: https://platform.claude.com/docs/en/about-claude/pricing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
|
||||
def get_anthropic_pricing_entries() -> list:
|
||||
"""Return official docs pricing entries for Anthropic Claude models."""
|
||||
from agent.usage_pricing import PricingEntry
|
||||
|
||||
_ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
|
||||
_ANTHROPIC_PRICING_VER = "anthropic-pricing-2026-05"
|
||||
|
||||
return [
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-opus-4-7")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-opus-4-6")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-opus-4-5")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-sonnet-4-7")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-sonnet-4-6")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-sonnet-4-5")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-haiku-4-5")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("1.00"),
|
||||
output_cost_per_million=Decimal("5.00"),
|
||||
cache_read_cost_per_million=Decimal("0.10"),
|
||||
cache_write_cost_per_million=Decimal("1.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-7-sonnet")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("1.00"),
|
||||
output_cost_per_million=Decimal("5.00"),
|
||||
cache_read_cost_per_million=Decimal("0.10"),
|
||||
cache_write_cost_per_million=Decimal("1.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-6-sonnet")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-5-sonnet")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-7-opus")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-6-opus")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-5-opus")
|
||||
PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url=_ANTHROPIC_PRICING_URL,
|
||||
pricing_version=_ANTHROPIC_PRICING_VER,
|
||||
), # key: ("anthropic", "claude-4-5-haiku")
|
||||
]
|
||||
|
||||
|
||||
# Model name keys for the pricing entries — must match the order above
|
||||
ANTHROPIC_PRICING_KEYS = [
|
||||
("anthropic", "claude-opus-4-7"),
|
||||
("anthropic", "claude-opus-4-6"),
|
||||
("anthropic", "claude-opus-4-5"),
|
||||
("anthropic", "claude-sonnet-4-7"),
|
||||
("anthropic", "claude-sonnet-4-6"),
|
||||
("anthropic", "claude-sonnet-4-5"),
|
||||
("anthropic", "claude-haiku-4-5"),
|
||||
("anthropic", "claude-4-7-sonnet"),
|
||||
("anthropic", "claude-4-6-sonnet"),
|
||||
("anthropic", "claude-4-5-sonnet"),
|
||||
("anthropic", "claude-4-7-opus"),
|
||||
("anthropic", "claude-4-6-opus"),
|
||||
("anthropic", "claude-4-5-opus"),
|
||||
("anthropic", "claude-4-5-haiku"),
|
||||
]
|
||||
|
||||
|
||||
def normalize_anthropic_model_name(model: str) -> str:
|
||||
"""Normalize Anthropic model name variants to canonical form.
|
||||
|
||||
Handles:
|
||||
- Dot notation: claude-opus-4.7 → claude-opus-4-7
|
||||
- Short aliases: claude-opus-4.7 → claude-opus-4-7
|
||||
- Strips anthropic/ prefix if present
|
||||
"""
|
||||
import re
|
||||
name = model.lower().strip()
|
||||
if name.startswith("anthropic/"):
|
||||
name = name[len("anthropic/"):]
|
||||
# Normalize dots to dashes in version numbers
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
return name
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Anthropic provider resolver for auxiliary client construction.
|
||||
|
||||
Handles ALL provider-specific logic for building auxiliary clients:
|
||||
credential resolution (pool, env var, OAuth), client construction,
|
||||
base URL detection, and transport wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from utils import base_url_hostname
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint detection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks Anthropic Messages protocol.
|
||||
|
||||
Covers:
|
||||
- Any URL ending in ``/anthropic``
|
||||
- ``api.kimi.com/coding`` (Kimi Coding Plan)
|
||||
- ``api.anthropic.com`` (native Anthropic)
|
||||
"""
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
return True
|
||||
if hostname == "api.kimi.com" and "/coding" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
"""Detect if an endpoint expects Anthropic-format content blocks."""
|
||||
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
|
||||
return True
|
||||
url_lower = (base_url or "").lower()
|
||||
return "/anthropic" in url_lower
|
||||
|
||||
|
||||
def convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks."""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
converted.append(msg)
|
||||
continue
|
||||
new_content = []
|
||||
changed = False
|
||||
for block in content:
|
||||
if block.get("type") == "image_url":
|
||||
image_url_val = (block.get("image_url") or {}).get("url", "")
|
||||
if image_url_val.startswith("data:"):
|
||||
header, _, b64data = image_url_val.partition(",")
|
||||
media_type = "image/png"
|
||||
if ":" in header and ";" in header:
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": image_url_val,
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
new_content.append(block)
|
||||
converted.append({**msg, "content": new_content} if changed else msg)
|
||||
return converted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport wrapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
|
||||
"""Return False instead of raising when a patched symbol is not a type."""
|
||||
try:
|
||||
return isinstance(obj, maybe_type)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def maybe_wrap_anthropic(
|
||||
client_obj: Any,
|
||||
model: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
api_mode: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
|
||||
the endpoint actually speaks Anthropic Messages.
|
||||
|
||||
Returns ``client_obj`` unchanged when it's already a specialized adapter
|
||||
or the endpoint is OpenAI-wire.
|
||||
"""
|
||||
from agent.anthropic_aux import AnthropicAuxiliaryClient
|
||||
|
||||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
|
||||
# Check for other specialized adapters we should never re-dispatch.
|
||||
try:
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
if _safe_isinstance(client_obj, GeminiNativeClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if _safe_isinstance(client_obj, CopilotACPClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Explicit non-anthropic api_mode wins over URL heuristics.
|
||||
if api_mode and api_mode != "anthropic_messages":
|
||||
return client_obj
|
||||
|
||||
should_wrap = (
|
||||
api_mode == "anthropic_messages"
|
||||
or endpoint_speaks_anthropic_messages(base_url)
|
||||
)
|
||||
if not should_wrap:
|
||||
return client_obj
|
||||
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
if build_anthropic_client is None:
|
||||
logger.warning(
|
||||
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
|
||||
"not installed — falling back to OpenAI-wire (will likely 404).",
|
||||
base_url,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
real_client = build_anthropic_client(api_key, base_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to build Anthropic client for %s (%s) — falling back to "
|
||||
"OpenAI-wire client.", base_url, exc,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
logger.debug(
|
||||
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
|
||||
"(model=%s, base_url=%s, api_mode=%s)",
|
||||
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
|
||||
)
|
||||
return AnthropicAuxiliaryClient(
|
||||
real_client, model, api_key, base_url, is_oauth=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pool helpers (thin wrappers over core pool functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
|
||||
"""Return (pool_exists_for_provider, selected_entry)."""
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(provider)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary client: could not load pool for %s: %s", provider, exc)
|
||||
return False, None
|
||||
if not pool or not pool.has_credentials():
|
||||
return False, None
|
||||
try:
|
||||
return True, pool.select()
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary client: could not select pool entry for %s: %s", provider, exc)
|
||||
return True, None
|
||||
|
||||
|
||||
def _pool_runtime_api_key(entry: Any) -> str:
|
||||
if entry is None:
|
||||
return ""
|
||||
key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
return str(key or "").strip()
|
||||
|
||||
|
||||
def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
|
||||
if entry is None:
|
||||
return str(fallback or "").strip().rstrip("/")
|
||||
url = (
|
||||
getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
or fallback
|
||||
)
|
||||
return str(url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _get_aux_model_for_provider(provider_id: str) -> str:
|
||||
"""Return the cheap auxiliary model for a provider."""
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
_p = get_provider_profile(provider_id)
|
||||
if _p and _p.default_aux_model:
|
||||
return _p.default_aux_model
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The resolver: called by core's resolve_provider_client()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_auxiliary_client(
|
||||
*,
|
||||
model: str | None = None,
|
||||
explicit_api_key: str | None = None,
|
||||
explicit_base_url: str | None = None,
|
||||
async_mode: bool = False,
|
||||
is_vision: bool = False,
|
||||
main_runtime: dict | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
"""Resolve an auxiliary client for the Anthropic provider.
|
||||
|
||||
Returns ``(client, default_model)`` or ``(None, None)`` if unavailable.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
from agent.anthropic_aux import (
|
||||
AnthropicAuxiliaryClient,
|
||||
AsyncAnthropicAuxiliaryClient,
|
||||
)
|
||||
|
||||
_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:
|
||||
return None, None
|
||||
|
||||
pool_present, entry = _select_pool_entry("anthropic")
|
||||
if pool_present:
|
||||
if entry is None:
|
||||
return None, None
|
||||
token = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
else:
|
||||
entry = None
|
||||
token = explicit_api_key or resolve_anthropic_token()
|
||||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
if explicit_base_url:
|
||||
base_url = explicit_base_url.strip().rstrip("/")
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_is_oauth_token = _anthropic.get("_is_oauth_token")
|
||||
is_oauth = _is_oauth_token(token) if _is_oauth_token else False
|
||||
default_model = model or _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
|
||||
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", default_model, base_url, is_oauth)
|
||||
try:
|
||||
real_client = build_anthropic_client(token, base_url)
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
client = AnthropicAuxiliaryClient(real_client, default_model, token, base_url, is_oauth=is_oauth)
|
||||
|
||||
if async_mode:
|
||||
client = AsyncAnthropicAuxiliaryClient(client)
|
||||
|
||||
return client, default_model
|
||||
@@ -1,20 +0,0 @@
|
||||
[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*"]
|
||||
@@ -1,180 +0,0 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Remove sys.path entries that would shadow the real ``anthropic`` SDK.
|
||||
|
||||
pytest adds ``plugins/model-providers/`` to ``sys.path`` because
|
||||
``plugins/model-providers/anthropic/__init__.py`` (a provider profile)
|
||||
exists. This makes ``import anthropic`` find the plugin directory
|
||||
instead of the installed SDK package, causing ``AttributeError:
|
||||
module 'anthropic' has no attribute 'Anthropic'``.
|
||||
|
||||
We remove the conflicting entry, evict any wrong cached import, and
|
||||
force-import the real SDK so sys.modules["anthropic"] is correct even
|
||||
after pytest re-adds the conflicting path during collection.
|
||||
"""
|
||||
import importlib
|
||||
_repo_root = Path(__file__).resolve().parent.parent.parent.parent # main/
|
||||
_bad = str(_repo_root / "plugins" / "model-providers")
|
||||
while _bad in sys.path:
|
||||
sys.path.remove(_bad)
|
||||
# Evict wrong import
|
||||
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
|
||||
del sys.modules["anthropic"]
|
||||
# Force-import the real SDK now (before pytest re-adds the bad path)
|
||||
# so sys.modules["anthropic"] points to the real package.
|
||||
try:
|
||||
import anthropic as _real_anthropic # noqa: F401
|
||||
if not hasattr(_real_anthropic, "Anthropic"):
|
||||
raise ImportError("wrong anthropic module loaded")
|
||||
except ImportError:
|
||||
# Try explicit import from venv
|
||||
import importlib.util as _ilu
|
||||
for _p in sys.path:
|
||||
_candidate = Path(_p) / "anthropic" / "__init__.py"
|
||||
if _candidate.exists() and (_candidate.parent / "_client.py").exists():
|
||||
_spec = _ilu.spec_from_file_location("anthropic", _candidate)
|
||||
if _spec and _spec.loader:
|
||||
_mod = _ilu.module_from_spec(_spec)
|
||||
sys.modules["anthropic"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
break
|
||||
|
||||
|
||||
|
||||
class _FullCtx:
|
||||
"""Plugin context that wires up all registry hooks the anthropic plugin uses.
|
||||
|
||||
Uses the real registries for provider_services, provider_resolver,
|
||||
credential_pool_hook, transport, and pricing so plugin internals work
|
||||
correctly. Everything else is a no-op so the fixture doesn't depend on
|
||||
parts of the system (platform, TTS, etc.) that aren't under test.
|
||||
"""
|
||||
|
||||
def register_provider_services(self, name, services):
|
||||
from agent.plugin_registries import registries
|
||||
registries.register_provider_services(name, services)
|
||||
|
||||
def register_provider_resolver(self, name, resolver):
|
||||
from agent.plugin_registries import registries
|
||||
registries.register_provider_resolver(name, resolver)
|
||||
|
||||
def register_credential_pool_hook(self, name, hook):
|
||||
from agent.plugin_registries import registries
|
||||
registries.register_credential_pool_hook(name, hook)
|
||||
|
||||
def register_transport(self, api_mode, transport_cls):
|
||||
from agent.plugin_registries import registries
|
||||
registries._transports[api_mode] = transport_cls
|
||||
|
||||
def register_pricing_provider(self, name, fn):
|
||||
from agent.plugin_registries import registries
|
||||
registries.register_pricing_provider(name, fn)
|
||||
|
||||
def register_provider_overlay(self, entry):
|
||||
from agent.plugin_registries import registries
|
||||
registries.register_provider_overlay(entry)
|
||||
|
||||
# Catch-all no-op for every other register_* method (platform, TTS,
|
||||
# tools, hooks, skills, etc.) so the fixture never crashes when the
|
||||
# plugin calls something we don't need to wire up for unit tests.
|
||||
def __getattr__(self, name):
|
||||
if name.startswith("register_"):
|
||||
return lambda *a, **kw: None
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Calls the plugin's ``register()`` against a full context so that all
|
||||
registry hooks (services, resolver, transport, pricing, etc.) are
|
||||
populated. patch.dict on each affected registry dict guarantees clean
|
||||
teardown even across conftest scopes.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
# Snapshot current state so we can restore after the test.
|
||||
_prev_services = dict(registries._provider_services)
|
||||
_prev_resolvers = dict(registries._provider_resolvers)
|
||||
_prev_cph = dict(registries._credential_pool_hooks)
|
||||
_prev_transports = dict(registries._transports) if hasattr(registries, "_transports") else {}
|
||||
_prev_pricing = dict(registries._pricing_providers) if hasattr(registries, "_pricing_providers") else {}
|
||||
_prev_overlays = dict(registries._provider_overlays) if hasattr(registries, "_provider_overlays") else {}
|
||||
|
||||
ctx = _FullCtx()
|
||||
try:
|
||||
from hermes_agent_anthropic import register as _reg # type: ignore[import]
|
||||
_reg(ctx)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
# Restore — remove keys the plugin added, put back what was there before.
|
||||
for d, prev in [
|
||||
(registries._provider_services, _prev_services),
|
||||
(registries._provider_resolvers, _prev_resolvers),
|
||||
(registries._credential_pool_hooks, _prev_cph),
|
||||
]:
|
||||
d.clear()
|
||||
d.update(prev)
|
||||
for attr, prev in [
|
||||
("_transports", _prev_transports),
|
||||
("_pricing_providers", _prev_pricing),
|
||||
("_provider_overlays", _prev_overlays),
|
||||
]:
|
||||
if hasattr(registries, attr):
|
||||
getattr(registries, attr).clear()
|
||||
getattr(registries, attr).update(prev)
|
||||
|
||||
|
||||
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
|
||||
@@ -1,420 +0,0 @@
|
||||
"""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("agent.transports.anthropic.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("agent.transports.anthropic.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("agent.transports.anthropic.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("agent.transports.anthropic.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
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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
|
||||
@@ -1,535 +0,0 @@
|
||||
"""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 (
|
||||
resolve_provider_client,
|
||||
_read_codex_access_token,
|
||||
_resolve_auto,
|
||||
get_available_vision_backends,
|
||||
call_llm,
|
||||
async_call_llm,
|
||||
)
|
||||
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
|
||||
from agent.anthropic_aux import AnthropicAuxiliaryClient
|
||||
|
||||
|
||||
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 hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
|
||||
from agent.anthropic_aux import 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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
|
||||
mock_build.return_value = MagicMock()
|
||||
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
|
||||
from agent.anthropic_aux import 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.credential_pool.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 hermes_agent_anthropic.resolve import resolve_auxiliary_client as _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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
|
||||
mock_build.return_value = MagicMock()
|
||||
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
|
||||
client, model = _try_anthropic(explicit_api_key="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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
|
||||
mock_build.return_value = MagicMock()
|
||||
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _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("hermes_agent_anthropic.resolve._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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"), \
|
||||
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
||||
patch("hermes_agent_anthropic.resolve._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", "eyJhbG...test.sig") # JWT → is_oauth=True
|
||||
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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"),
|
||||
):
|
||||
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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"),
|
||||
):
|
||||
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": "fresh-token",
|
||||
"refresh_token": "refresh-token-2",
|
||||
"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
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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 agent.anthropic_format 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 agent.anthropic_format 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 agent.anthropic_format 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
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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 agent.anthropic_format 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
|
||||
@@ -1,231 +0,0 @@
|
||||
"""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 agent.anthropic_format 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 agent.anthropic_format 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 agent.anthropic_format 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 agent.anthropic_format 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"
|
||||
@@ -1,22 +0,0 @@
|
||||
"""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
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Tests for the AnthropicMessagesTransport.
|
||||
|
||||
Behavioral tests that require the real anthropic transport implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent.transports import get_transport
|
||||
from agent.transports.types import NormalizedResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport():
|
||||
"""Load the real Anthropic transport by registering the plugin."""
|
||||
from hermes_agent_anthropic import register as _anthro_register
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
class _Ctx:
|
||||
def register_transport(self, api_mode, obj):
|
||||
from agent.transports import register_transport
|
||||
register_transport(api_mode, obj)
|
||||
def register_provider_resolver(self, name, fn):
|
||||
registries.register_provider_resolver(name, fn)
|
||||
def register_provider_services(self, name, services):
|
||||
registries.register_provider_services(name, services)
|
||||
def register_credential_pool_hook(self, name, hook):
|
||||
registries.register_credential_pool_hook(name, hook)
|
||||
def register_pricing_provider(self, name, entries):
|
||||
registries.register_pricing_provider(name, entries)
|
||||
def register_provider_overlay(self, entry):
|
||||
registries.register_provider_overlay(entry)
|
||||
def __getattr__(self, name):
|
||||
if name.startswith("register_"):
|
||||
return lambda *a, **kw: None
|
||||
raise AttributeError(name)
|
||||
|
||||
_anthro_register(_Ctx())
|
||||
return get_transport("anthropic_messages")
|
||||
|
||||
|
||||
|
||||
class TestAnthropicTransportBehavioral:
|
||||
|
||||
# (fixture defined at module level above)
|
||||
|
||||
def test_api_mode(self, transport):
|
||||
assert transport.api_mode == "anthropic_messages"
|
||||
|
||||
def test_convert_tools_simple(self, transport):
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test_tool",
|
||||
"description": "A test",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
}]
|
||||
result = transport.convert_tools(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "test_tool"
|
||||
assert "input_schema" in result[0]
|
||||
|
||||
def test_validate_response_none(self, transport):
|
||||
assert transport.validate_response(None) is False
|
||||
|
||||
def test_validate_response_empty_content(self, transport):
|
||||
r = SimpleNamespace(content=[])
|
||||
assert transport.validate_response(r) is False
|
||||
|
||||
def test_validate_response_empty_content_with_end_turn_is_valid(self, transport):
|
||||
r = SimpleNamespace(content=[], stop_reason="end_turn")
|
||||
assert transport.validate_response(r) is True
|
||||
|
||||
def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport):
|
||||
r = SimpleNamespace(content=[], stop_reason="tool_use")
|
||||
assert transport.validate_response(r) is False
|
||||
|
||||
def test_validate_response_valid(self, transport):
|
||||
r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")])
|
||||
assert transport.validate_response(r) is True
|
||||
|
||||
def test_map_finish_reason(self, transport):
|
||||
assert transport.map_finish_reason("end_turn") == "stop"
|
||||
assert transport.map_finish_reason("tool_use") == "tool_calls"
|
||||
assert transport.map_finish_reason("max_tokens") == "length"
|
||||
assert transport.map_finish_reason("stop_sequence") == "stop"
|
||||
assert transport.map_finish_reason("refusal") == "content_filter"
|
||||
assert transport.map_finish_reason("model_context_window_exceeded") == "length"
|
||||
assert transport.map_finish_reason("unknown") == "stop"
|
||||
|
||||
def test_extract_cache_stats_none_usage(self, transport):
|
||||
r = SimpleNamespace(usage=None)
|
||||
assert transport.extract_cache_stats(r) is None
|
||||
|
||||
def test_extract_cache_stats_with_cache(self, transport):
|
||||
usage = SimpleNamespace(cache_read_input_tokens=100, cache_creation_input_tokens=50)
|
||||
r = SimpleNamespace(usage=usage)
|
||||
result = transport.extract_cache_stats(r)
|
||||
assert result == {"cached_tokens": 100, "creation_tokens": 50}
|
||||
|
||||
def test_extract_cache_stats_zero(self, transport):
|
||||
usage = SimpleNamespace(cache_read_input_tokens=0, cache_creation_input_tokens=0)
|
||||
r = SimpleNamespace(usage=usage)
|
||||
assert transport.extract_cache_stats(r) is None
|
||||
|
||||
def test_normalize_response_text(self, transport):
|
||||
"""Test normalization of a simple text response."""
|
||||
r = SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="Hello world")],
|
||||
stop_reason="end_turn",
|
||||
usage=SimpleNamespace(input_tokens=10, output_tokens=5),
|
||||
model="claude-sonnet-4-6",
|
||||
)
|
||||
nr = transport.normalize_response(r)
|
||||
assert isinstance(nr, NormalizedResponse)
|
||||
assert nr.content == "Hello world"
|
||||
assert nr.tool_calls is None or nr.tool_calls == []
|
||||
assert nr.finish_reason == "stop"
|
||||
|
||||
def test_normalize_response_tool_calls(self, transport):
|
||||
"""Test normalization of a tool-use response."""
|
||||
r = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(
|
||||
type="tool_use",
|
||||
id="toolu_123",
|
||||
name="terminal",
|
||||
input={"command": "ls"},
|
||||
),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
usage=SimpleNamespace(input_tokens=10, output_tokens=20),
|
||||
model="claude-sonnet-4-6",
|
||||
)
|
||||
nr = transport.normalize_response(r)
|
||||
assert nr.finish_reason == "tool_calls"
|
||||
assert len(nr.tool_calls) == 1
|
||||
tc = nr.tool_calls[0]
|
||||
assert tc.name == "terminal"
|
||||
assert tc.id == "toolu_123"
|
||||
assert '"command"' in tc.arguments
|
||||
|
||||
def test_normalize_response_thinking(self, transport):
|
||||
"""Test normalization preserves thinking content."""
|
||||
r = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="thinking", thinking="Let me think..."),
|
||||
SimpleNamespace(type="text", text="The answer is 42"),
|
||||
],
|
||||
stop_reason="end_turn",
|
||||
usage=SimpleNamespace(input_tokens=10, output_tokens=15),
|
||||
model="claude-sonnet-4-6",
|
||||
)
|
||||
nr = transport.normalize_response(r)
|
||||
assert nr.content == "The answer is 42"
|
||||
assert nr.reasoning == "Let me think..."
|
||||
|
||||
def test_build_kwargs_returns_dict(self, transport):
|
||||
"""Test build_kwargs produces a usable kwargs dict."""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=messages,
|
||||
max_tokens=1024,
|
||||
)
|
||||
assert isinstance(kw, dict)
|
||||
assert "model" in kw
|
||||
assert "max_tokens" in kw
|
||||
assert "messages" in kw
|
||||
|
||||
def test_convert_messages_extracts_system(self, transport):
|
||||
"""Test convert_messages separates system from messages."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
system, msgs = transport.convert_messages(messages)
|
||||
# System should be extracted
|
||||
assert system is not None
|
||||
# Messages should only have user
|
||||
assert len(msgs) >= 1
|
||||
@@ -11,9 +11,3 @@ arcee = ProviderProfile(
|
||||
)
|
||||
|
||||
register_provider(arcee)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""No-op — this provider has no workspace package yet."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -19,9 +19,3 @@ 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)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""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,
|
||||
})
|
||||
|
||||
# Register the provider resolver — core dispatches to this instead of
|
||||
# having a per-azure-foundry if/elif branch in resolve_provider_client().
|
||||
from hermes_agent_azure.resolve import resolve_auxiliary_client as _azure_resolver
|
||||
ctx.register_provider_resolver("azure-foundry", _azure_resolver)
|
||||
|
||||
# Register the provider overlay — core merges this into HERMES_OVERLAYS
|
||||
from agent.plugin_registries import ProviderOverlayEntry
|
||||
ctx.register_provider_overlay(ProviderOverlayEntry(
|
||||
provider_name="azure-foundry",
|
||||
transport="openai_chat", # default; overridden by api_mode in config
|
||||
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
|
||||
display_name="Azure AI Foundry",
|
||||
aliases=[],
|
||||
))
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Azure Foundry provider resolver for auxiliary client construction.
|
||||
|
||||
Handles ALL provider-specific logic for building auxiliary clients:
|
||||
Entra ID auth, static API key, base URL resolution, api_mode routing
|
||||
(chat_completions, codex_responses, anthropic_messages).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_url_query_params(url: str):
|
||||
"""Extract query params from URL, return (clean_url, default_query dict or None)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.query:
|
||||
clean = urlunparse(parsed._replace(query=""))
|
||||
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||
return clean, params
|
||||
return url, None
|
||||
|
||||
|
||||
def _normalize_resolved_model(model: str, provider: str) -> str:
|
||||
"""Normalize model name for a given provider."""
|
||||
return str(model or "").strip()
|
||||
|
||||
|
||||
def resolve_auxiliary_client(
|
||||
*,
|
||||
model: str | None = None,
|
||||
explicit_api_key: str | None = None,
|
||||
explicit_base_url: str | None = None,
|
||||
async_mode: bool = False,
|
||||
is_vision: bool = False,
|
||||
main_runtime: dict | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
"""Resolve an Azure Foundry auxiliary client via the runtime resolver.
|
||||
|
||||
Mirrors the anthropic/bedrock resolver shape but delegates to
|
||||
``hermes_cli.runtime_provider._resolve_azure_foundry_runtime`` —
|
||||
the same resolver the main agent uses — so:
|
||||
|
||||
* ``auth_mode: api_key`` (default) gets the static
|
||||
``AZURE_FOUNDRY_API_KEY`` string.
|
||||
* ``auth_mode: entra_id`` gets a callable bearer-token provider
|
||||
(``Callable[[], str]`` from the azure identity adapter).
|
||||
* Per-model ``api_mode`` auto-routing for GPT-5.x / o-series /
|
||||
codex models works.
|
||||
* ``model.entra.{tenant_id,client_id,authority,scope}`` config
|
||||
fields propagate.
|
||||
* Non-default ``model.base_url`` overrides are honored.
|
||||
|
||||
Returns ``(client, model)`` or ``(None, None)`` on failure.
|
||||
"""
|
||||
from openai import OpenAI
|
||||
|
||||
try:
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.config import load_config
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
except Exception:
|
||||
model_cfg = {}
|
||||
|
||||
try:
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg=model_cfg,
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
target_model=model,
|
||||
)
|
||||
except AuthError as exc:
|
||||
logger.debug("Auxiliary azure-foundry: %s", exc)
|
||||
return None, None
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary azure-foundry runtime error: %s", exc)
|
||||
return None, None
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = str(runtime.get("base_url", "") or "")
|
||||
runtime_api_mode = api_mode or runtime.get("api_mode") or "chat_completions"
|
||||
|
||||
_has_key = bool(api_key) if not callable(api_key) else True
|
||||
if not _has_key or not base_url:
|
||||
return None, None
|
||||
|
||||
final_model = _normalize_resolved_model(
|
||||
model or str(model_cfg.get("default") or ""),
|
||||
"azure-foundry",
|
||||
)
|
||||
if not final_model:
|
||||
logger.debug(
|
||||
"Auxiliary azure-foundry: no model resolved (model=%r, default=%r)",
|
||||
model, model_cfg.get("default"),
|
||||
)
|
||||
return None, None
|
||||
|
||||
extra: dict[str, Any] = {}
|
||||
_clean_base, _dq = _extract_url_query_params(base_url)
|
||||
if _dq:
|
||||
extra["default_query"] = _dq
|
||||
|
||||
client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
|
||||
|
||||
if runtime_api_mode == "codex_responses":
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
return CodexAuxiliaryClient(client, final_model), final_model
|
||||
|
||||
if runtime_api_mode == "anthropic_messages":
|
||||
from agent.plugin_registries import registries
|
||||
maybe_wrap = registries.get_provider_service("anthropic", "maybe_wrap_anthropic")
|
||||
if maybe_wrap is not None:
|
||||
return maybe_wrap(
|
||||
client, final_model, api_key,
|
||||
base_url, runtime_api_mode,
|
||||
), final_model
|
||||
|
||||
return client, final_model
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user