Feat/anthropic on vertex - #61859
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused Vertex integration and for identifying the auxiliary resolver gap.
Problems
- Blocking:
agent/agent_runtime_helpers.py:1891-1927still constructs everyanthropic_messagesswitch withbuild_anthropic_client(...). The added Vertex snapshot block in this PR is later in the same function (agent/agent_runtime_helpers.py, new diff line 2077) and does not change that construction. A running session switched to Vertex Claude would therefore not createAnthropicVertex. Please add the parallel Vertex branch here and test a Gemini-on-Vertex → Claude-on-Vertex switch plus restore. website/docs/guides/anthropic-vertex.md:13says bareclaude-*routes through AnthropicVertex, contradicting the intended strict-prefix behavior documented at lines 76 and 90. The implementation classifier also only acceptsanthropic/.
Suggested changes
- Build
AnthropicVertexin theswitch_model()Anthropic Messages branch using the resolved Vertex project/region, then snapshot the same fields for restore/recovery. - Correct the guide’s introductory dispatch statement.
Automated hermes-sweeper review.
Addresses review feedback on PR NousResearch#61859 (@teknium1 via hermes-sweeper): the anthropic_messages branch in agent_runtime_helpers.switch_model() was unconditionally calling build_anthropic_client, so a Gemini-on- Vertex session that ran /model anthropic/claude-opus-4-8 mid-turn built the wrong client — native Anthropic against api.anthropic.com instead of AnthropicVertex against the project's Vertex endpoint. The restore/recover branches already had the parallel vertex construction (added in the original commit at agent_runtime_helpers.py:1020 and :1206) but switch_model was missed. Adds the parallel new_provider == "vertex" branch inside the anthropic_messages guard. Resolves project/region freshly via get_anthropic_vertex_config() (switch_model has no runtime dict to consume from) and stashes _vertex_project_id / _vertex_region on the agent so subsequent restore/recover sites can rebuild without a config re-read. Placeholder api_key="vertex-adc" matches the runtime- provider convention. Also corrects the guide intro paragraph the same review flagged: the :::info block at website/docs/guides/anthropic-vertex.md:13 was left over from an earlier draft that accepted bare claude-* names. The classifier is strict-prefix (only anthropic/... routes through AnthropicVertex); the intro now says so explicitly, matching lines 76 and 90 of the same guide and the is_anthropic_vertex_model implementation. New tests in tests/run_agent/test_switch_model_vertex_anthropic.py cover the exact scenario the reviewer asked for: - Gemini-on-Vertex -> Claude-on-Vertex mid-session switch builds AnthropicVertex, NOT native Anthropic. Verifies the resolved project/region reach the client factory positionally and the agent state (api_key placeholder, _vertex_project_id/region, _is_anthropic_oauth=False, client=None) is coherent. - region=None from get_anthropic_vertex_config falls back to "global" (matches the runtime-provider default). - Regression guard: a switch onto the native Anthropic provider still uses build_anthropic_client, unaffected by the fix. Verification: scripts/run_tests.sh on the impacted file set (169 tests across 8 files including primary_runtime_restore, user_providers_model_switch, copilot_api_mode, and the existing switch_model pool-reload regression) all pass with no new failures. (cherry picked from commit e78e8ba31f6ed293ccb3987784b6d308bd1180c7)
The NousResearch#47828 guard rejects a /model switch to a new provider with an empty base_url. Claude-on-Vertex (api_mode=anthropic_messages, provider=vertex) legitimately has no base_url — the AnthropicVertex SDK builds its endpoint from project/region — so a mid-session Gemini-on-Vertex -> Claude-on-Vertex switch raised instead of constructing the client. Exempt that one case. Integration fix on top of cherry-picked PR NousResearch#61859 (Anthropic on Vertex).
|
@teknium1 Thanks for the review! - all should be fixed now. Can you please have another look? |
|
While this PR is still pending, I’ve implemented and tested a working alternative that runs Hermes with Claude on Vertex AI through a dedicated compatibility bridge. It supports tool calls, prompt caching, retries, Workload Identity, and production-style GKE deployment without requiring changes to Hermes core: |
|
@teknium1 Did you have a chance to review my updates? |
|
@teknium1 pinging your again too see how we can advance this PR. |
Route Anthropic Claude models on Google Vertex AI through the existing
``vertex`` provider, dispatching on the requested model name. Same
Anthropic Messages wire as native Anthropic (prompt caching, adaptive
thinking, tool-use streaming, ``xhigh`` effort), but authenticated with
Google-cloud OAuth2 (ADC or service-account JSON) and billed through
GCP.
Rationale
=========
Vertex AI has hosted Anthropic Claude models as partner models since
2024, but Hermes's existing ``vertex`` provider is Gemini-only (it uses
Vertex's OpenAI-compatible aggregator at ``.../endpoints/openapi``).
Reaching Claude on Vertex from Hermes today requires operators to fall
back to third-party proxies or forgo the GCP billing / quotas /
compliance surface entirely — even though Anthropic ships the
``AnthropicVertex`` SDK client that handles it cleanly and Hermes
already knows how to talk the Anthropic Messages protocol through
``api_mode="anthropic_messages"``.
Design
======
Vertex hosts multiple model families on one platform. Rather than
introduce a second provider name, treat the ``vertex`` provider as the
GCP-hosted aggregator and dispatch the wire protocol from the requested
model name:
* ``anthropic/<model>`` → ``anthropic_messages`` transport via the
``AnthropicVertex`` SDK.
* ``google/gemini-*`` (or anything else) → existing ``chat_completions``
transport via Vertex's OpenAI-compat aggregator.
This mirrors Bedrock's dual-path shape (``is_anthropic_bedrock_model``),
with one deliberate difference: Vertex requires the fully-qualified
``anthropic/`` vendor prefix. Bedrock accepts bare ``claude-*`` as a
legacy shortcut from the era when Bedrock was Anthropic-only; Vertex
Model Garden is multi-vendor from day one, so a bare Claude name has
no unambiguous meaning and the classifier deliberately rejects it. A
user writing ``model: claude-opus-4-8`` under ``provider: vertex``
falls through to the OpenAI-compat aggregator and gets a clear Vertex
404 that names the missing prefix, rather than silent, model-shape-
guessing dispatch. One provider, one config surface
(``vertex.project_id`` / ``vertex.region``), one explicit wire
selector.
Users who already have Gemini on Vertex working get Anthropic on Vertex
with a one-line ``model.default`` change.
New adapter module ``agent/anthropic_vertex_adapter.py`` exposes:
- ``build_anthropic_vertex_client(project_id, region, timeout)`` —
constructs an ``anthropic.AnthropicVertex`` client with a fresh
google-auth Credentials object. Mirrors ``build_anthropic_bedrock_
client(region)`` in shape and beta-header policy: common Anthropic
betas attached (``interleaved-thinking-2025-05-14``, ``fine-grained-
tool-streaming-2025-05-14``); ``context-1m-2025-08-07`` NOT attached
by default (Vertex-hosted Claude gates 1M context on the same
Anthropic-side subscription flag as native Anthropic). SDK-level
retries disabled — Hermes's outer loop handles Retry-After.
- ``get_anthropic_vertex_config(region=None)`` — returns
``(project_id, region)`` from the shared ``vertex_adapter`` config
chain (VERTEX_PROJECT_ID / vertex.project_id / credentials-embedded).
- ``has_anthropic_vertex_credentials()`` — fast presence check for
auto-detection and setup-status UI, no network calls.
- ``build_anthropic_vertex_base_url(project_id, region)`` — display-only
URL for the runtime dict / diagnostics; the AnthropicVertex SDK
builds its own real request URLs internally.
- ``is_anthropic_vertex_model(model_id)`` — the dispatch classifier
used by ``resolve_runtime_provider``. Matches vendor-prefixed
``anthropic/<model>`` only (case-insensitive, whitespace-tolerant);
bare Claude names and non-string inputs safely return False.
Auth reuses ``agent/vertex_adapter.py``'s credential-resolution helpers
(``_resolve_credentials_path``, ``_resolve_project_override``,
``_resolve_region``). Gemini-on-Vertex and Anthropic-on-Vertex share
one config surface — the two families are different wire protocols on
the same underlying platform.
Wiring
======
- ``hermes_cli/runtime_provider.py``: extended the ``vertex`` branch
with a model-name dispatch — an ``anthropic/`` prefixed model →
returns an ``anthropic_messages`` runtime dict (placeholder
``api_key="vertex-adc"`` since the SDK mints its own tokens;
``vertex_project_id`` / ``vertex_region`` fields for the client-
construction sites to consume); everything else → existing
``chat_completions`` path. Raises ``AuthError`` with an actionable
message pointing at the Vertex Model Garden enablement flow when
credentials or the project can't be resolved for a Claude request.
- ``agent/agent_init.py``: new branch in the ``anthropic_messages``
transport-selection block that constructs ``AnthropicVertex`` instead
of the regular ``Anthropic`` client when ``agent.provider == "vertex"``
(unambiguous inside the anthropic_messages guard — Gemini on Vertex
uses ``chat_completions`` and never reaches this branch). Stashes
``_vertex_project_id`` / ``_vertex_region`` on the agent for
rebuild-site consumption. Startup banner says "Anthropic on Vertex
AI, <project>/<region>".
- ``run_agent._rebuild_anthropic_client``: parallel branch mirroring the
bedrock branch — reconstructs an ``AnthropicVertex`` on interrupt /
stale-call recovery.
- ``agent/agent_runtime_helpers.py`` (2 rebuild sites +
``switch_model`` snapshot): parallel branches so restore / fallback /
mid-session model swap all recreate the vertex client from the
``_primary_runtime`` snapshot without hitting disk.
- ``hermes_cli/model_normalize.py``: new ``vertex`` branch strips the
``anthropic/`` vendor prefix on the way to the wire (the
AnthropicVertex SDK substitutes the request-body ``model`` field
verbatim into ``publishers/anthropic/models/{model}:rawPredict``, so a
leading ``anthropic/`` would corrupt the URL). ``google/gemini-*``
and every other form passes through unchanged — the OpenAI-compat
aggregator wants the ``google/`` prefix intact.
- ``hermes_cli/models.py``: ``vertex`` provider description updated to
reflect the mixed-family dispatch.
- ``tools/vision_tools.py``: ``vertex`` in the vision-capable
aggregator set already covers both Claude and Gemini on Vertex, no
new entry needed.
Docs + tests
============
- ``website/docs/guides/anthropic-vertex.md`` — quick start,
configuration (``provider: vertex``, ``model: anthropic/claude-…``),
dispatch table (including the "bare Claude falls through" row so
users understand the failure mode), per-region model availability
caveat, feature parity note, common failure modes.
- ``website/docs/integrations/providers.md`` — Anthropic-on-Vertex
entry pointing at the same ``vertex`` provider row as Gemini.
- ``tests/agent/test_anthropic_vertex_adapter.py`` — unit tests
covering base-URL construction, credential resolution (missing
google-auth / ADC / explicit override), client construction
(missing SDK / old SDK / missing creds / expected kwargs), and the
``is_anthropic_vertex_model`` classifier (vendor-prefixed accepted,
bare Claude rejected as regression guard, case-insensitive,
whitespace-tolerant, negative and non-string cases).
- ``tests/hermes_cli/test_anthropic_vertex_provider.py`` — end-to-end
runtime-dispatch tests: ``anthropic/`` model on all ``vertex``
aliases routes through anthropic_messages; bare ``claude-*`` on
``vertex`` falls through to chat_completions (regression guard for
the strict-prefix decision); Gemini on ``vertex`` still routes
through chat_completions (regression guard confirming the Anthropic
adapter is not consulted); removed provider name and aliases no
longer resolve; model-normalization contract (strip
``anthropic/``, preserve everything else); actionable AuthErrors
when credentials / project_id can't be resolved.
Model IDs on Vertex use Anthropic's native names with an optional
``@YYYYMMDD`` suffix (``claude-opus-4-8``, ``claude-sonnet-4-5``,
``claude-haiku-4-5``, …). The exact set enabled for a given GCP
project is gated on a one-time Model Garden click per model —
undocumented models 404 with the same shape as unknown model names,
so the guide calls this out explicitly under Prerequisites and Common
Failure Modes.
Auxiliary tasks (title generation, memory extraction, vision) are NOT
wired to route through Vertex when the primary provider is
``vertex + anthropic/*`` in this PR — the existing ``_try_anthropic``
fallback in ``agent/auxiliary_client.py`` targets the native Anthropic
API. Operators who want anthropic-vertex for aux tasks can set
``auxiliary.<task>.provider = "vertex"`` with an ``anthropic/`` model
explicitly; the codepath will pick it up in a follow-up.
Silent regression on any `provider: vertex` deployment:
`resolve_provider_client("vertex", ...)` returned `(None, None)` because
`PROVIDER_REGISTRY.get("vertex")` was None, and the existing
`elif pconfig.auth_type == "vertex":` handler below was unreachable
dead code.
Every auxiliary task on a Vertex-only deployment — `vision_analyze`,
context compression, curator's LLM review pass, `session_search`,
`title_generation`, `web_extract` — fell through to the aggregator
fallback chain (openrouter → nous → local/custom → api-key) and
terminated with:
RuntimeError: No LLM provider configured for
task=<task> provider=auto. Run: hermes setup
Vision was hit hardest: `check_vision_requirements()` uses the same
resolver, so it returned False and `vision_analyze` was stripped from
the model-facing tool schema. Fleets with `OPENROUTER_API_KEY` set
never noticed because Step 3 of `_resolve_auto` caught the miss;
Vertex-only deployments hit it terminally.
Root cause is in `hermes_cli/auth.py`'s auto-extension of
`PROVIDER_REGISTRY` from the provider-plugin catalog:
if _pp.auth_type != "api_key" or not _pp.env_vars:
continue
The vertex `ProviderProfile` declares `auth_type="vertex"` (OAuth2 via
ADC, not a static key) and `env_vars=()`, so both clauses reject it.
Same shape applies to any future non-api_key provider profile
(aws_sdk, oauth_device_code, oauth_external).
Fix: plugin-catalog fallback for `pconfig=None`. When the registry
lookup misses, consult `providers.get_provider_profile(provider)`. If
the profile's `auth_type` is one of the well-known non-api_key
families (`vertex`, `aws_sdk`, `oauth_device_code`, `oauth_external`),
synthesize a `SimpleNamespace(auth_type=<profile.auth_type>)` and let
the existing dispatch branches downstream fire. Genuinely unknown
providers still bail cleanly with the unchanged "unknown provider"
debug log.
No touch to `hermes_cli/auth.py` — keeping `PROVIDER_REGISTRY`
api-key-only preserves invariants elsewhere in the codebase.
Once the fallback is in place, the pre-existing
`elif pconfig.auth_type == "vertex":` handler serves Gemini traffic
via the OpenAI-compat endpoint as it was originally written to do —
no other changes needed here.
Tests: new `tests/agent/test_auxiliary_client_vertex_dispatch.py` (10
cases, all hermetic — mock the credential seams only):
- `TestPluginCatalogFallback` (3): vertex reaches dispatch through
the fallback; aliases (`google-vertex`, `vertex-ai`, `gcp-vertex`)
resolve; genuinely-unknown providers still bail.
- `TestVertexGeminiDispatch` (6): `google/`-prefixed model builds an
OpenAI client with the right base_url + token; no-model default;
bare `gemini-*` still routes to the Gemini handler (Vertex's
404 for the missing publisher stays the loud-fail diagnostic);
credential / token failure paths; async wrapper.
- `TestHistoricalRegression` (1): pins the invariant with `vertex`
forcibly removed from `PROVIDER_REGISTRY`, so a future refactor
of the `auth.py` auto-extension filter cannot silently
reintroduce the bug.
No user-facing behaviour change on non-Vertex deployments.
Follow-up to the plugin-catalog fallback fix (previous commit) and the
Anthropic-on-Vertex feature (`feat(providers): Anthropic on Google
Vertex AI`). Extends the vertex handler in
``agent.auxiliary_client.resolve_provider_client`` so that Anthropic
Claude auxiliary calls route through ``AnthropicVertex`` (native
Messages wire) instead of the Gemini OpenAI-compat aggregator path,
mirroring ``hermes_cli/runtime_provider.py``'s main-agent dispatch.
Without this commit, an operator with ``model.provider: vertex,
model.default: anthropic/claude-*`` gets the main chat working
correctly (main-agent path builds ``AnthropicVertex``) but every
auxiliary task (vision_analyze, compression, curator, session_search,
title_generation, web_extract) routes through the Gemini handler and
400s with "Malformed publisher model (`model`: 'claude-...') for the
'openapi' request endpoint ID".
Two hunks in ``agent/auxiliary_client.py``, plus a follow-up widening
for prefix-stripping:
1. Anthropic-vs-Gemini split inside the vertex handler. Mirrors
``bedrock_adapter.is_anthropic_bedrock_model``'s aws_sdk-branch
pattern:
if model and is_anthropic_vertex_model(model):
# Claude on Vertex → AnthropicVertex SDK
...
else:
# Existing Gemini OpenAI-compat aggregator path
...
``is_anthropic_vertex_model`` is imported lazily via
``try/except ImportError`` so the aux-dispatch commit above
stands alone even if this commit is not applied — the classifier
defaults to ``lambda: False`` and every vertex aux call falls
through to Gemini, which is the correct behaviour when the
Anthropic-on-Vertex feature is absent.
2. Widen aux-side detection to bare ``claude-*`` (case-insensitive):
_model_lc = (model or "").strip().lower()
_is_anthropic = (
is_anthropic_vertex_model(model)
or _model_lc.startswith("claude-")
)
``is_anthropic_vertex_model`` (defined in the feature commit)
intentionally requires the fully-qualified ``anthropic/<model>``
form so main-agent config typos surface as a loud Vertex 404
(``publisher: google, model: claude-...``) instead of silently
misrouting. The auxiliary path sees the model AFTER
``agent_init.py::normalize_model_for_provider(model, "vertex")``
has stripped the ``anthropic/`` prefix, so
``_read_main_model()`` returns bare ``claude-opus-4-8``. If aux
dispatch also required the prefix, every Vertex-Anthropic aux
call would silently misroute to the Gemini path.
The strict classifier stays strict for main-agent — the widening
is scoped to the aux dispatch site where prefix stripping has
already happened. Mirrors ``is_anthropic_bedrock_model``'s
dual-form acceptance (``anthropic.claude-*`` AND bare
``claude-*``) in the one place where it's actually necessary.
Tests: extends the existing ``tests/agent/test_auxiliary_client_
vertex_dispatch.py`` with ``TestVertexAnthropicDispatch`` (9 cases
covering the SDK dispatch, prefix stripping, sentinel api_key, base
URL for billing attribution, missing-creds / missing-project /
missing-SDK bail paths, uppercase prefix, bare ``claude-*``, and the
async wrapper). All 19 cases in the file pass hermetically with only
the credential seams and SDK factory mocked.
No user-facing behaviour change unless the operator has configured
``model.provider: vertex, model.default: anthropic/*``.
Addresses review feedback on PR NousResearch#61859 (@teknium1 via hermes-sweeper): the anthropic_messages branch in agent_runtime_helpers.switch_model() was unconditionally calling build_anthropic_client, so a Gemini-on- Vertex session that ran /model anthropic/claude-opus-4-8 mid-turn built the wrong client — native Anthropic against api.anthropic.com instead of AnthropicVertex against the project's Vertex endpoint. The restore/recover branches already had the parallel vertex construction (added in the original commit at agent_runtime_helpers.py:1020 and :1206) but switch_model was missed. Adds the parallel new_provider == "vertex" branch inside the anthropic_messages guard. Resolves project/region freshly via get_anthropic_vertex_config() (switch_model has no runtime dict to consume from) and stashes _vertex_project_id / _vertex_region on the agent so subsequent restore/recover sites can rebuild without a config re-read. Placeholder api_key="vertex-adc" matches the runtime- provider convention. Also corrects the guide intro paragraph the same review flagged: the :::info block at website/docs/guides/anthropic-vertex.md:13 was left over from an earlier draft that accepted bare claude-* names. The classifier is strict-prefix (only anthropic/... routes through AnthropicVertex); the intro now says so explicitly, matching lines 76 and 90 of the same guide and the is_anthropic_vertex_model implementation. New tests in tests/run_agent/test_switch_model_vertex_anthropic.py cover the exact scenario the reviewer asked for: - Gemini-on-Vertex -> Claude-on-Vertex mid-session switch builds AnthropicVertex, NOT native Anthropic. Verifies the resolved project/region reach the client factory positionally and the agent state (api_key placeholder, _vertex_project_id/region, _is_anthropic_oauth=False, client=None) is coherent. - region=None from get_anthropic_vertex_config falls back to "global" (matches the runtime-provider default). - Regression guard: a switch onto the native Anthropic provider still uses build_anthropic_client, unaffected by the fix. Verification: scripts/run_tests.sh on the impacted file set (169 tests across 8 files including primary_runtime_restore, user_providers_model_switch, copilot_api_mode, and the existing switch_model pool-reload regression) all pass with no new failures.
Extends the previous commit's Vertex branch to the sibling provider with the identical defect. ``switch_model``'s ``anthropic_messages`` branch now dispatches bedrock as well, matching ``agent_init``, ``run_agent._rebuild_anthropic_client`` and ``run_agent._create_request_anthropic_client``, all three of which already special-case ``provider == "bedrock"``. Bedrock-hosted Claude speaks the Anthropic Messages protocol but authenticates through the AWS SDK, against a base_url that has no ``/v1/messages`` route. So ``/model claude-sonnet-4-5`` in a live Bedrock session replaced a working AnthropicBedrock client with a direct Anthropic one and every call after the switch failed. Restarting the session recovered it (agent_init gets it right), which is likely why it went unnoticed longer than the Vertex variant. Region resolution prefers the endpoint being switched TO — the same regex agent_init.py runs over the resolved base_url — then the region stashed at init, then ``us-east-1``. ``_bedrock_region`` is refreshed on the agent so the ``_primary_runtime`` snapshot taken later in this same function records the destination rather than the origin. Adds tests/agent/test_switch_model_anthropic_provider_dispatch.py, which asserts the cross-site invariant behind both halves of this bug — that ``switch_model`` and ``_rebuild_anthropic_client`` agree on which SDK each provider gets — rather than snapshotting either implementation. It covers vertex, bedrock and native Anthropic, that no client is ever built against the display-only Vertex publisher base_url, and that switching INTO vertex works without the init-stashed attributes. The narrower tests/run_agent/test_switch_model_vertex_anthropic.py from the previous commit is kept as-is. This also reconciles the branch with the deployment fork (zarmory/hermes-agent `khala`), which had independently grown the same bedrock+vertex fix while chasing a production 404. Both now carry equivalent coverage, so a future rebase of this branch onto current upstream/main won't have to untangle two divergent versions of one fix. Verification: scripts/run_tests.sh over the new file plus the existing vertex, anthropic-adapter and model-switch suites — 342 passed. The 3 failures in TestRunOauthSetupToken are pre-existing on this branch's base and reproduce with this change stashed.
…client Upstream NousResearch#67142 (42c240f) added ``AIAgent._create_request_anthropic_client`` — a per-request Anthropic client so the stale/interrupt watchdog can abort a socket without closing the shared client out from under a worker thread. It became the client that carries every in-flight ``anthropic_messages`` call (both call sites in ``agent/chat_completion_helpers.py``: the non-streaming ``make_client`` lambda and the ``anthropic_stream_request`` path). Its docstring says it mirrors ``_rebuild_anthropic_client``, but it only reproduced the direct-Anthropic and Bedrock branches — the Claude-on-Vertex branch was missing. On a ``provider: vertex`` + ``model: anthropic/claude-*`` deployment every turn fell through to ``build_anthropic_client()`` with ``api_key="vertex-adc"`` and ``base_url`` set to the *display-only* Vertex publisher URL, so the SDK POSTed to …/publishers/anthropic/v1/messages -> HTTP 404 instead of the real publisher route …/publishers/anthropic/models/<model>:rawPredict Symptom: "The model provider failed after retries" on every message, with ``NotFoundError`` / ``HTTP 404`` and ``provider=vertex`` in errors.log. The failure was invisible to init-time and auxiliary checks. The shared ``_anthropic_client`` is correctly an ``AnthropicVertex`` (verified in situ: constructing the agent with the gateway's own ``_resolve_runtime_agent_kwargs`` yields ``anthropic.lib.vertex._client.AnthropicVertex``), and the auxiliary path builds its own Vertex client in ``resolve_provider_client``. So agent startup, ``hermes --version``, and all six auxiliary probes pass while the main conversation loop 404s on its first API call. Add the ``elif _provider == "vertex"`` branch, mirroring ``_rebuild_anthropic_client`` (project + region read from the ``_vertex_project_id`` / ``_vertex_region`` attributes stashed during init, region defaulting to ``global``), and note in the docstring that the two builders' provider dispatch must stay in sync. Tests: tests/agent/test_request_anthropic_client_vertex_dispatch.py. Rather than snapshot either implementation, the central test asserts the invariant that broke — that ``_create_request_anthropic_client`` and ``_rebuild_anthropic_client`` agree on which SDK each provider gets — plus that no client is ever built against the display-only publisher base_url. All four fail without this change (the invariant test reports ``{'vertex': 0, 'direct': 1}`` vs ``{'vertex': 1, 'direct': 0}``). Upstream-PR candidate: this is a gap in NousResearch#67142 that affects any Claude-on-Vertex user, independent of our fork's other commits.
…h guard `switch_model` gained a guard (NousResearch#47828) that raises when a provider change resolves an empty `base_url`, on the premise that empty means upstream resolution failed and keeping the previous provider's endpoint would send every subsequent request to the wrong host. That premise does not hold for the Anthropic cloud partner SDKs. `AnthropicVertex` and `AnthropicBedrock` derive their endpoint from project/region internally (`…/publishers/anthropic/models/<model>:rawPredict`), so `switch_model()` correctly resolves no base_url for them. With the guard as-is, `/model anthropic/claude-*` on `provider: vertex` raises ValueError instead of switching: ValueError: switch_model: no base_url resolved for provider 'vertex' (switching from 'openrouter'); refusing to keep the previous provider's endpoint Nothing is inherited in the exempted case either: the branch leaves `agent.base_url` untouched and the Anthropic client is rebuilt from project/region, not from `agent.base_url`. Scoped deliberately to `api_mode == "anthropic_messages"` and provider in {vertex, bedrock}. Gemini-on-Vertex goes through the OpenAI-compat aggregator and does need a real base_url, so it stays under the guard. Tests pin the interaction from both sides: the Vertex switch survives an empty base_url, and the guard still fires for an ordinary provider change and for Vertex outside `anthropic_messages`. Upstream's own guard test (tests/run_agent/test_switch_model_stale_base_url.py) still passes unchanged.
8b418ed to
cd44d48
Compare
|
Rebased onto current The rebase conflictOne conflict, in Added: the request-local client was still undispatched
That gap is invisible to a clean rebase, so I verified it mechanically rather than by reading. Classifying every
The removed flag is exactly Added: the empty-base_url guard blocks Claude on Vertex
Exempted, scoped to Tests pin it from both sides — the Vertex switch survives an empty base_url, and the guard still fires for an ordinary provider change and for Vertex outside Your earlier reviewBoth points from the 2026-07-10 review are in and still hold after the rebase:
Verification
This is running in production on a two-VM fleet against |
Addresses review feedback on PR NousResearch#61859 (@teknium1 via hermes-sweeper): the anthropic_messages branch in agent_runtime_helpers.switch_model() was unconditionally calling build_anthropic_client, so a Gemini-on- Vertex session that ran /model anthropic/claude-opus-4-8 mid-turn built the wrong client — native Anthropic against api.anthropic.com instead of AnthropicVertex against the project's Vertex endpoint. The restore/recover branches already had the parallel vertex construction (added in the original commit at agent_runtime_helpers.py:1020 and :1206) but switch_model was missed. Adds the parallel new_provider == "vertex" branch inside the anthropic_messages guard. Resolves project/region freshly via get_anthropic_vertex_config() (switch_model has no runtime dict to consume from) and stashes _vertex_project_id / _vertex_region on the agent so subsequent restore/recover sites can rebuild without a config re-read. Placeholder api_key="vertex-adc" matches the runtime- provider convention. Also corrects the guide intro paragraph the same review flagged: the :::info block at website/docs/guides/anthropic-vertex.md:13 was left over from an earlier draft that accepted bare claude-* names. The classifier is strict-prefix (only anthropic/... routes through AnthropicVertex); the intro now says so explicitly, matching lines 76 and 90 of the same guide and the is_anthropic_vertex_model implementation. New tests in tests/run_agent/test_switch_model_vertex_anthropic.py cover the exact scenario the reviewer asked for: - Gemini-on-Vertex -> Claude-on-Vertex mid-session switch builds AnthropicVertex, NOT native Anthropic. Verifies the resolved project/region reach the client factory positionally and the agent state (api_key placeholder, _vertex_project_id/region, _is_anthropic_oauth=False, client=None) is coherent. - region=None from get_anthropic_vertex_config falls back to "global" (matches the runtime-provider default). - Regression guard: a switch onto the native Anthropic provider still uses build_anthropic_client, unaffected by the fix. Verification: scripts/run_tests.sh on the impacted file set (169 tests across 8 files including primary_runtime_restore, user_providers_model_switch, copilot_api_mode, and the existing switch_model pool-reload regression) all pass with no new failures.
What does this PR do?
Adds first-class Anthropic Claude on Google Vertex AI as a supported runtime path under the existing
vertexprovider name. Users who already run Gemini on Vertex can flip to Claude with a one-line config change:Anthropic and Google document the offering on both vendor sides:
Related Issue
provider: vertex#61852. The middle commit here fixes it; the last commit adds Anthropic-specific dispatch on top.Integrating this feature on a Vertex-only deployment surfaced a pre-existing bug in
agent/auxiliary_client.pythat silently disabled every auxiliary task (vision, compression, curator, session_search, title_generation, web_extract) on anyprovider: vertexdeployment, regardless of model family. The main-chat surface has its own dispatch path (hermes_cli/runtime_provider.py) that special-casesvertexup front, so Gemini-on-Vertex worked at the main-chat level while auxiliary tasks were quietly failing.That fix is filed as sibling #61853. The middle commit of this PR (
fix(auxiliary_client): route vertex through the auth_type dispatch) is bit-identical to the above's single commit — either PR can land first:Both PRs stand alone. Merging just this PR is fully self-contained; merging just #61853 fixes Gemini-on-Vertex aux tooling without the Claude feature.
Type of Change
e978baec3and0addf0c8ed211336a5(also filed as fix(auxiliary_client): route vertex through the auth_type dispatch #61853)Non-breaking. Routes through the existing
vertexprovider; users who don't setmodel.defaultto ananthropic/-prefixed name see zero behavior change.Changes Made
Commit 1 —
feat(providers): Anthropic on Google Vertex AI(e978baec3)New adapter module —
agent/anthropic_vertex_adapter.py:build_anthropic_vertex_client(project_id, region, timeout)constructs ananthropic.AnthropicVertexwith a fresh google-authCredentialsobject. Beta-header policy mirrorsbuild_anthropic_bedrock_client: the common Anthropic betas are attached (interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14);context-1m-2025-08-07is not attached — see Design decisions Architecture planning #3. SDK-level retries are disabled; Hermes's outer loop handles Retry-After.get_anthropic_vertex_config(),has_anthropic_vertex_credentials(),build_anthropic_vertex_base_url()are helpers forresolve_runtime_providerand the doctor / setup-status UI. They reuseagent/vertex_adapter.py's credential-resolution helpers (_resolve_credentials_path,_resolve_project_override,_resolve_region) so both wire paths undervertexshare one config surface.is_anthropic_vertex_model(model_id)is the runtime dispatch classifier. Matchesanthropic/<model>only (case-insensitive, whitespace-tolerant); non-string inputs safely returnFalse. See Design decisions Support passing morph snapshot id #2.Runtime dispatch —
hermes_cli/runtime_provider.py:Extended the
vertexbranch ofresolve_runtime_providerwith a model-name dispatch. Whenis_anthropic_vertex_model(target_model)is true, we return ananthropic_messagesruntime dict (placeholderapi_key="vertex-adc"since the SDK mints its own tokens per request;vertex_project_id/vertex_regionfields for the client-construction sites). Everything else takes the existingchat_completionspath. RaisesAuthErrorwith an actionable message pointing at the Vertex Model Garden enablement flow when credentials or the project can't be resolved for a Claude request.The model config is sourced via
_get_model_config()into a locally-scoped variable, deliberately named_vertex_model_cfgrather than the more obviousmodel_cfg— Python's static scoping makes any baremodel_cfgname in this function local for the whole function body (because a later fallback branch assigns to it), which would tripUnboundLocalErroron any call path that passestarget_model=None(cron scheduler + gateway per-turn agent resolve both do). Regression test intest_vertex_dispatch_when_target_model_is_none.Client construction and rebuild sites — parallel branches mirroring the existing bedrock branches:
agent/agent_init.py— new branch inside theanthropic_messagestransport-selection block constructsAnthropicVertexwhenagent.provider == "vertex". Stashes_vertex_project_id/_vertex_regionon the agent for rebuild-site consumption.run_agent.py::_rebuild_anthropic_client— parallel branch reconstructsAnthropicVertexon interrupt / stale-call recovery.agent/agent_runtime_helpers.py(2 rebuild sites plus theswitch_modelsnapshot) — parallel branches so restore / fallback / mid-session model swap all recreate the client from the_primary_runtimesnapshot without hitting disk.Model-name normalization —
hermes_cli/model_normalize.py:New
vertexbranch strips theanthropic/vendor prefix on the way to the wire.AnthropicVertexsubstitutes the request body'smodelfield verbatim into.../publishers/anthropic/models/{model}:rawPredict, so a leadinganthropic/corrupts the URL. Gemini'sgoogle/gemini-…naming passes through unchanged — the OpenAI-compat aggregator wants the prefix intact.Small housekeeping —
hermes_cli/models.py,tools/vision_tools.py:ProviderEntrydescription onvertexupdated to reflect that the provider now hosts Gemini + Claude on the same platform. No new aliases, no new auth-type entries, no picker changes — the existingvertexauth flow covers both wire paths.Docs —
website/docs/:guides/anthropic-vertex.md— new guide with quick start, configuration, dispatch table, per-region model-availability caveat, feature parity notes, and common failure modes. Explicitly documents that 1M context is GA on Vertex-hosted Opus 4.6+ / Sonnet 4.6+ with no beta header required.integrations/providers.md— Anthropic-on-Vertex entry pointing at the samevertexprovider row as Gemini.Commit 2 —
fix(auxiliary_client): route vertex through the auth_type dispatch(d211336a5)Bit-identical to #61853 single commit. Summary:
resolve_provider_client("vertex", …)silently returned(None, None)on any Vertex deployment becausePROVIDER_REGISTRY.get("vertex") is None(the auto-extension inhermes_cli/auth.pyfilters out non-api_key providers), so the existingelif pconfig.auth_type == "vertex":handler below was unreachable dead code.The fix is a plugin-catalog fallback for
pconfig=None: when the registry lookup misses, consultproviders.get_provider_profile(name)and synthesize a minimal pconfig so the downstreamauth_typedispatch runs. Genuinely unknown providers still bail cleanly.This commit alone fixes Gemini-on-Vertex aux tooling for every existing user. It's a prerequisite for the Anthropic-on-Vertex aux dispatch in commit 3.
Commit 3 —
feat(auxiliary_client): route Anthropic-on-Vertex through the native SDK(0addf0c8e)Extends the vertex handler (which commit 2 made reachable) with an Anthropic-vs-Gemini split, mirroring
bedrock_adapter.is_anthropic_bedrock_model's aws_sdk-branch pattern:The lazy
try/except ImportErrorkeeps commit 2 fully standalone — onmain(or with #61853 merged alone), the classifier defaults tolambda: Falseand every vertex aux call routes to Gemini. Once this commit is applied, the classifier activates.Why the aux dispatch widens to bare
claude-*too:is_anthropic_vertex_modelintentionally requires theanthropic/<model>vendor prefix on the main-agent side — that's the loud-fail contract for config typos (see Design decisions #2). But the auxiliary path sees the model AFTERagent_init.py::normalize_model_for_provider(model, "vertex")has stripped the prefix andset_runtime_mainhas recorded the bare form._read_main_model()returns bareclaude-opus-4-8. If the aux dispatch also required the prefix, every Vertex-Anthropic aux call would silently misroute to the Gemini path and 400 with "Malformed publisher model". The widening mirrorsis_anthropic_bedrock_model's dual-form acceptance in the one place where it's actually necessary — the strict main-agent classifier is untouched.Test file extension —
tests/agent/test_auxiliary_client_vertex_dispatch.py(adds 11 cases on top of the 10 from commit 2):TestVertexAnthropicDispatch(9) —anthropic/-prefixed model routes toAnthropicAuxiliaryClientwrappingAnthropicVertex; model normalized to bare form on the wire; placeholderapi_key="vertex-adc"; base_url shape matches the Vertex publisher endpoint (for billing attribution); missing-creds / missing-project / missing-SDK bail paths; uppercase prefix; bareclaude-*widening (case-insensitive); async wrapper.Total for the vertex dispatch test file: 21 cases, all hermetic (mock the credential seams and SDK factory only).
How to Test
Focused test files:
scripts/run_tests.sh tests/agent/test_anthropic_vertex_adapter.py \ tests/hermes_cli/test_anthropic_vertex_provider.py \ tests/hermes_cli/test_model_normalize.py \ tests/agent/test_auxiliary_client_vertex_dispatch.py \ -q # ─── All tests pass.Broader regression sweep across sibling test files (provider dispatch, runtime resolution,
anthropic_messagestransport, primary-runtime restore, model switch, vision routing, provider profiles, bedrock parity):scripts/run_tests.sh tests/hermes_cli/test_anthropic_vertex_provider.py \ tests/agent/test_anthropic_vertex_adapter.py \ tests/agent/test_auxiliary_client_vertex_dispatch.py \ tests/agent/test_auxiliary_client.py \ tests/agent/test_auxiliary_main_first.py \ tests/agent/test_vertex_adapter.py \ tests/agent/test_auxiliary_client_azure_foundry.py \ tests/agent/test_auxiliary_client_anthropic_custom.py \ tests/agent/test_auxiliary_client_resolve_dedup.py \ tests/agent/test_bedrock_adapter.py \ tests/agent/test_bedrock_integration.py \ tests/hermes_cli/test_model_normalize.py \ tests/hermes_cli/test_models.py \ tests/hermes_cli/test_gemini_provider.py \ tests/hermes_cli/test_setup.py \ tests/hermes_cli/test_cli_provider_resolution.py \ tests/hermes_cli/test_status_model_provider.py \ tests/providers/test_provider_profiles.py \ tests/providers/test_profile_wiring.py \ tests/tools/test_vision_tools.py \ tests/tools/test_vision_native_fast_path.py \ tests/agent/test_vision_routing_31179.py \ tests/agent/test_context_compressor.py \ tests/tools/test_session_search.py \ tests/agent/test_auxiliary_config_bridge.py \ tests/run_agent/test_provider_fallback.py \ tests/run_agent/test_primary_runtime_restore.py \ tests/run_agent/test_streaming.py \ tests/agent/transports/ \ tests/gateway/test_runtime_footer.py \ -q # ─── 37 files, 1776/1776 tests pass in ~25s.Real-world validation. Deployed on a Vertex-only GCE fleet (NixOS, ADC via VM service-account) with
model.default: anthropic/claude-opus-4-8.hermes -z "what LLM are you?"confirmsclaude-opus-4-8 served via the Vertex AI provider. The main-chat loop handles live tool-use turns; prompt caching and interleaved thinking on Opus verified via session traces.vision_analyze,title_generation,compression,session_search,curator, andweb_extractall round-trip through the Anthropic path in <10s each.Large-context sanity check. A 40-turn multi-turn accumulator test drove a peak single-call payload of ~675K tokens through hermes →
AnthropicVertex→ Vertex →claude-opus-4-8. Latency stayed flat at ~27s per turn across all 40 turns (prompt caching dominant); the model correctly recalled sentinel tokens from both the first and last chunks in its final answer. That's 3.4× over the 200K pre-GA cap and matches Anthropic's public GA statement that the beta header is a no-op on Vertex.Manual reproduction on a fresh checkout:
Checklist
Code
feat(providers): …,fix(auxiliary_client): …).scripts/run_tests.shon the affected files (all pass — 1776/1776 across the broader sweep).TestHistoricalRegressionthat pins the aux-dispatch invariant against future refactors ofhermes_cli/auth.py.Documentation & Housekeeping
website/docs/guides/anthropic-vertex.md; new entry inwebsite/docs/integrations/providers.md.cli-config.yaml.example— N/A. No new config keys;model.default/model.provider/vertex.*all already exist.CONTRIBUTING.md/AGENTS.md— N/A. No architecture or workflow changes.Follow-ups (out of scope for this PR)
Filing here for reviewer visibility, not asking to bundle in:
hermes_cli/setup.py::_VERTEX_MODELScurrently lists onlygoogle/gemini-*SKUs. Extending it to also surfaceanthropic/claude-opus-4-8/-sonnet-4-5/-haiku-4-5in the picker would make the wizard aware of both families. Would benefit from a small UI hint (which SKUs are Anthropic vs. Google) so users understand the Model-Garden-enablement gate applies to Anthropic entries.compressionon Vertex-Anthropic deployments now routes to the main Claude model by default (via_resolve_autoStep 1). That's the intended behaviour, but Opus is expensive for what's essentially summarisation. Deployments where compression fires frequently may want to pinauxiliary.compression.provider: vertex, auxiliary.compression.model: google/gemini-3.5-flashexplicitly. Not this PR's concern — it's a config-time choice for operators.Screenshots / Logs
Startup banner on the running agent:
Turn round-trip against Opus 4.8 via Vertex: