Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bcee1ba
feat(vertex): route Claude models through the AnthropicVertex SDK
nickkpoon Jul 17, 2026
ed47881
fix(vertex): send x-goog-user-project header for user-ADC quota billing
nickkpoon Jul 17, 2026
c5e1085
chore(vertex): refresh Claude picker suggestions to current lineup
nickkpoon Jul 17, 2026
58c5b59
fix(vertex): retry transient MaaS concurrency 429s with backoff
nickkpoon Jul 18, 2026
cb3dfff
fix(vertex): add curated model list so /model picker enumerates verte…
nickkpoon Jul 18, 2026
f43b9b1
feat(vertex): route Claude models through AnthropicVertex in auxiliar…
nickkpoon Jul 18, 2026
a88cb95
fix(aux): preserve Anthropic cache/native usage fields through _Anthr…
nickkpoon Jul 18, 2026
1d2c0a3
fix(vertex): show Google Vertex AI in the /model picker when ADC/SA c…
nickkpoon Jul 20, 2026
c535671
fix(vertex): centralize Claude client construction; model-aware api_m…
nickkpoon Jul 20, 2026
462feb5
fix(vertex): route per-request/fallback/refresh client builds through…
nickkpoon Jul 20, 2026
6a1ee48
fix(vertex): detect Claude-on-Vertex in the fallback-activation api_m…
nickkpoon Jul 20, 2026
6ba697f
fix(model-switch): add 'fable' short alias so /model fable resolves t…
nickkpoon Jul 21, 2026
4d75581
fix(vertex): recover auxiliary Gemini/openapi clients from ~1h OAuth …
nickkpoon Jul 22, 2026
412159f
feat(vertex): add claude-opus-4-8 to the curated Vertex model catalog
nickkpoon Jul 22, 2026
55a121b
fix: /branch HTTP 400 (whitespace placeholder) + duplicated pre-branc…
nickkpoon Jul 23, 2026
085f4e2
fix(vertex): repair merge fallout from upstream/main integration
nickkpoon Jul 24, 2026
33a65bf
chore: map nick.poon@irrigreen.com to nickkpoon for attribution check
nickkpoon Jul 24, 2026
f56fa88
fix(context): claude-opus-5 missing from 1M context tables
nickkpoon Aug 10, 2026
f5f642e
fix(vertex): keep one Claude generation per family in the curated cat…
nickkpoon Aug 10, 2026
b1a0757
test(vertex): sync _StubAgent with upstream's LM Studio switch-path c…
nickkpoon Aug 10, 2026
e99210f
test(vertex): mock refresh_vertex_credentials, not the retired get_ve…
nickkpoon Aug 10, 2026
39202d0
local: pricing keys for opus-5/fable-5, Claude-on-Vertex billing rout…
nickkpoon Aug 16, 2026
89ca050
test(vertex): sync _StubAgent with upstream's per-request Anthropic c…
nickkpoon Aug 16, 2026
cfb4c9e
feat(vertex): add google/gemini-3.7-flash (curated catalog, setup lis…
nickkpoon Aug 16, 2026
bf97a45
fix(vertex): route xai/grok-* billing to provider=xai + add Grok pric…
nickkpoon Aug 16, 2026
ffd8315
feat(vertex): support custom base_url for AnthropicVertex corporate p…
nickkpoon Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,11 @@ def init_agent(
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
# (prompt caching, thinking budgets, adaptive thinking).
_is_bedrock_anthropic = agent.provider == "bedrock"
# Vertex + Claude → use AnthropicVertex SDK. The SDK holds the
# google-auth Credentials object and refreshes the OAuth2 token
# itself, so the client survives long-lived sessions without a
# per-turn refresh hook.
_is_vertex_anthropic = agent.provider == "vertex"
if _is_bedrock_anthropic:
from agent.anthropic_adapter import build_anthropic_bedrock_client
_region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
Expand All @@ -1096,6 +1101,36 @@ def init_agent(
agent._client_kwargs = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch can be reached by anthropic/claude-*, because the new classifier accepts that alias, but the primary model normalizer does not strip an anthropic/ prefix for provider vertex (hermes_cli/model_normalize.py:412-417,453-467). Normalize the model to the bare Vertex publisher ID before constructing the client, and add a main-agent alias regression test; the auxiliary branch already performs that strip.

if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})")
elif _is_vertex_anthropic:
from agent.anthropic_adapter import build_anthropic_vertex_client
from agent.vertex_adapter import get_vertex_anthropic_config

# Cached resolve (runtime_provider already built + validated the
# Credentials object moments ago); this is a cheap cache read.
_vx_creds, _vx_project, _vx_region = get_vertex_anthropic_config()
if not _vx_project:
raise RuntimeError(
"Claude-on-Vertex selected but Vertex credentials could "
"not be resolved. Provide a service-account JSON via "
"GOOGLE_APPLICATION_CREDENTIALS / VERTEX_CREDENTIALS_PATH, "
"or run 'gcloud auth application-default login', and set "
"the GCP project/region under vertex: in config.yaml. "
"Install with: pip install 'hermes-agent[vertex]'."
)
agent._vertex_project_id = _vx_project
agent._vertex_region = _vx_region or "global"
agent._vertex_credentials = _vx_creds
agent._anthropic_client = build_anthropic_vertex_client(
_vx_project, agent._vertex_region, credentials=_vx_creds,
)
agent._anthropic_api_key = "vertex-oauth"
agent._anthropic_base_url = base_url
agent._is_anthropic_oauth = False
agent.api_key = "vertex-oauth"
agent.client = None
agent._client_kwargs = {}
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with model: {agent.model} (Google Vertex AI + AnthropicVertex SDK, project={_vx_project}, {agent._vertex_region})")
else:
# 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.
Expand Down
38 changes: 32 additions & 6 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1351,12 +1351,19 @@ 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
# Provider-aware: a Bedrock/Vertex primary must be rebuilt with
# its SDK client (AnthropicBedrock / AnthropicVertex) — rebuilding
# with build_anthropic_client() would point the recovered session
# at api.anthropic.com with the "aws-sdk"/"vertex-oauth"
# placeholder key and 401 every subsequent request.
from agent.anthropic_adapter import build_anthropic_client_for_provider
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
agent._anthropic_client = build_anthropic_client_for_provider(
agent.provider,
rt["anthropic_api_key"], rt["anthropic_base_url"],
timeout=get_provider_request_timeout(agent.provider, agent.model),
agent=agent,
)
agent._is_anthropic_oauth = rt["is_anthropic_oauth"]
agent.client = None
Expand Down Expand Up @@ -1607,12 +1614,18 @@ def restore_primary_runtime(agent) -> bool:
agent.client = build_moa_facade(agent, agent.model)
agent._anthropic_client = None
elif agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
# Provider-aware: restoring a Bedrock/Vertex primary must rebuild
# its SDK client (AnthropicBedrock / AnthropicVertex); the plain
# Anthropic client + "aws-sdk"/"vertex-oauth" placeholder key
# would 401 against api.anthropic.com on the next turn.
from agent.anthropic_adapter import build_anthropic_client_for_provider
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
agent._anthropic_client = build_anthropic_client_for_provider(
agent.provider,
rt["anthropic_api_key"], rt["anthropic_base_url"],
timeout=get_provider_request_timeout(agent.provider, agent.model),
agent=agent,
)
agent._is_anthropic_oauth = rt["is_anthropic_oauth"]
agent.client = None
Expand Down Expand Up @@ -2681,7 +2694,7 @@ def _restore_snapshot() -> None:
agent.client = build_moa_facade(agent, agent.model)
elif api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
build_anthropic_client_for_provider,
resolve_anthropic_token,
_is_oauth_token,
)
Expand All @@ -2691,6 +2704,14 @@ def _restore_snapshot() -> None:
_is_native_anthropic = new_provider == "anthropic"
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")

# SDK-auth providers carry no bearer key: Bedrock signs with
# SigV4, Vertex with a google-auth Credentials object. Pin the
# same placeholder keys agent_init uses so a stale key from the
# previous provider can't leak into logs/snapshots.
_sdk_placeholder_keys = {"bedrock": "aws-sdk", "vertex": "vertex-oauth"}
if new_provider in _sdk_placeholder_keys:
effective_key = _sdk_placeholder_keys[new_provider]

# MiniMax OAuth: swap static string for a per-request callable token
# provider so the rebuilt client survives 15-min token expiry. See
# the matching block in agent_init.py for the full rationale.
Expand All @@ -2709,9 +2730,14 @@ def _restore_snapshot() -> None:
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
agent._anthropic_client = build_anthropic_client(
# Provider-aware: switching to Claude-on-Vertex (or Bedrock) must
# build the SDK client — the plain Anthropic client would send the
# placeholder key to api.anthropic.com and 401.
agent._anthropic_client = build_anthropic_client_for_provider(
new_provider,
effective_key, agent._anthropic_base_url,
timeout=get_provider_request_timeout(agent.provider, agent.model),
agent=agent,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent.client = None
Expand Down
131 changes: 131 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,137 @@ def build_anthropic_bedrock_client(region: str):
)


def build_anthropic_vertex_client(
project_id: Optional[str],
region: str,
credentials=None,
base_url: Optional[str] = None,
):
"""Create an AnthropicVertex client for Claude-on-Vertex (Google Cloud).

Uses the Anthropic SDK's native Vertex adapter, which speaks the
Anthropic Messages protocol over Vertex's rawPredict / streamRawPredict
endpoints. This gives Claude on Google Cloud the same enhanced features
as native Anthropic — prompt caching, thinking budgets, adaptive
thinking, fine-grained tool streaming — that the OpenAI-compatible
Gemini endpoint cannot express.

Auth: passes the google-auth ``credentials`` object straight through so
the SDK mints and refreshes short-lived OAuth2 access tokens itself
(see anthropic.lib.vertex._client._ensure_access_token). Long-lived
gateway sessions therefore survive the ~1-hour token lifetime without a
per-turn refresh hook. When ``credentials`` is None the SDK falls back to
Application Default Credentials.

The 1M-context beta is intentionally NOT attached: Vertex Claude does not
honor the ``context-1m-2025-08-07`` beta the way Bedrock does, and sending
it can trigger a 400 on some model/region combos. Callers that want it can
add it per-request once Google enables it.
"""
_anthropic_sdk = _get_anthropic_sdk()
if _anthropic_sdk is None:
raise ImportError(
"The 'anthropic' package is required for the Vertex provider. "
"Install it with: pip install 'anthropic>=0.39.0'"
)
if not hasattr(_anthropic_sdk, "AnthropicVertex"):
raise ImportError(
"anthropic.AnthropicVertex not available. "
"Upgrade with: pip install 'anthropic>=0.39.0'"
)
from httpx import Timeout

_headers = {"anthropic-beta": ",".join(_COMMON_BETAS)}
# User ADC (authorized_user) requires the quota-project header on every
# aiplatform request — without it Vertex returns 403 "requires a quota
# project". Service accounts don't need it but tolerate it. google-auth's
# own transports attach this automatically; the Anthropic SDK uses its
# own httpx client, so we must set it explicitly.
if project_id:
_headers["x-goog-user-project"] = project_id
_kwargs = dict(
region=region,
credentials=credentials,
timeout=Timeout(timeout=900.0, connect=10.0),
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
# default max_retries=2 ignores it and double-retries. Mirrors the
# Bedrock client (#26293).
max_retries=0,
default_headers=_headers,
)
# Only pin project_id when we actually have one; otherwise let the SDK
# resolve it from the credentials / ADC (passing None would override that).
if project_id:
_kwargs["project_id"] = project_id
if base_url:
_kwargs["base_url"] = base_url
return _anthropic_sdk.AnthropicVertex(**_kwargs)


def build_anthropic_client_for_provider(
provider: Optional[str],
api_key,
base_url: Optional[str],
*,
timeout: Optional[float] = None,
drop_context_1m_beta: bool = False,
agent=None,
):
"""Provider-aware Anthropic client construction — the single chokepoint
for every path that (re)builds an ``anthropic_messages`` primary client.

``api_mode == "anthropic_messages"`` does not imply a direct Anthropic
endpoint: Bedrock needs ``AnthropicBedrock`` (SigV4) and Vertex needs
``AnthropicVertex`` (self-refreshing OAuth2 Credentials). Recovery,
restore, and provider-switch paths must build through here — calling
``build_anthropic_client()`` directly for those providers silently
points the session at api.anthropic.com with a placeholder key
("aws-sdk" / "vertex-oauth") and every subsequent request 401s.

For vertex, credentials are re-resolved (so a Credentials object
refreshed by another path is picked up) with the agent's cached
``_vertex_*`` attributes as fallback; the caches are refreshed when an
``agent`` is supplied. For bedrock, the region comes from the agent
cache, then the base_url, then us-east-1 — mirroring agent_init.
"""
provider_norm = (provider or "").strip().lower()

if provider_norm == "bedrock":
import re

_region = getattr(agent, "_bedrock_region", None) if agent is not None else None
if not _region:
_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
_region = _match.group(1) if _match else "us-east-1"
if agent is not None:
agent._bedrock_region = _region
return build_anthropic_bedrock_client(_region)

if provider_norm == "vertex":
from agent.vertex_adapter import get_vertex_anthropic_config

from agent.vertex_adapter import get_vertex_anthropic_base_url
_creds, _project, _region = get_vertex_anthropic_config()
_base_url = get_vertex_anthropic_base_url()
if agent is not None:
_project = _project or getattr(agent, "_vertex_project_id", None)
_region = _region or getattr(agent, "_vertex_region", None)
_creds = _creds or getattr(agent, "_vertex_credentials", None)
_region = _region or "global"
if agent is not None:
agent._vertex_project_id = _project
agent._vertex_region = _region
agent._vertex_credentials = _creds
return build_anthropic_vertex_client(_project, _region, credentials=_creds, base_url=_base_url)

return build_anthropic_client(
api_key,
base_url,
timeout=timeout,
drop_context_1m_beta=drop_context_1m_beta,
)


def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"""Read Claude Code OAuth credentials from the macOS Keychain.

Expand Down
Loading