diff --git a/agent/agent_init.py b/agent/agent_init.py index 7acd0f88ddc2..a7be43e0cc0e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1168,7 +1168,44 @@ def init_agent( # 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: + # Anthropic Claude on Vertex → use AnthropicVertex SDK. Same protocol + # as native Anthropic Messages, but authenticates via Google-cloud + # OAuth (ADC or service-account JSON) against Vertex's publisher-model + # endpoints. Project + region come from the vertex_adapter config + # (env vars + config.yaml + credentials-embedded project_id), which is + # the single source of truth shared with the Gemini-on-Vertex path. + # Same shape as ``_is_bedrock_anthropic``: one ``vertex`` provider, + # model-name-driven wire selection at resolve_runtime_provider time, + # detected here by the ``anthropic_messages`` api_mode we were handed. + _is_vertex_anthropic = agent.provider == "vertex" + if _is_vertex_anthropic: + from agent.anthropic_vertex_adapter import ( + build_anthropic_vertex_client, + get_anthropic_vertex_config, + ) + _project_id, _region = get_anthropic_vertex_config() + if not _project_id: + # runtime_provider.resolve_runtime_provider() already validated + # this at auth-resolution time — if it fails here, credentials + # were revoked mid-session (deleted SA file, revoked ADC token). + raise RuntimeError( + "Anthropic-on-Vertex credentials became unavailable during " + "agent init. Re-check GOOGLE_APPLICATION_CREDENTIALS / ADC." + ) + agent._vertex_project_id = _project_id + agent._vertex_region = _region + agent._anthropic_client = build_anthropic_vertex_client( + _project_id, _region, timeout=_provider_timeout, + ) + agent._anthropic_api_key = "vertex-adc" + agent._anthropic_base_url = base_url + agent._is_anthropic_oauth = False + agent.api_key = "vertex-adc" + agent.client = None + agent._client_kwargs = {} + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic on Vertex AI, {_project_id}/{_region})") + elif _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 "") _br_region = _region_match.group(1) if _region_match else "us-east-1" @@ -3139,6 +3176,19 @@ def _parse_prune_int(raw, default): "anthropic_base_url": agent._anthropic_base_url, "is_anthropic_oauth": agent._is_anthropic_oauth, }) + # Anthropic-on-Vertex needs project + region on the snapshot so + # restore/rebuild sites can reconstruct the AnthropicVertex client + # without re-reading config.yaml or credentials. Same reason bedrock + # stashes ``_bedrock_region`` — the rebuild path is turn-hot and + # should not hit disk. Guarded above by + # ``api_mode == "anthropic_messages"``, so ``provider == "vertex"`` + # here unambiguously means the Claude-on-Vertex dispatch (Gemini + # on Vertex uses ``chat_completions`` and never reaches this branch). + if agent.provider == "vertex": + agent._primary_runtime.update({ + "vertex_project_id": getattr(agent, "_vertex_project_id", None), + "vertex_region": getattr(agent, "_vertex_region", None), + }) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index c5eb9b34fc69..b08b72089326 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1489,14 +1489,28 @@ def try_recover_primary_transport( agent.request_overrides = dict(rt.get("request_overrides") or {}) if agent.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] - agent._anthropic_client = build_anthropic_client( - rt["anthropic_api_key"], rt["anthropic_base_url"], - timeout=get_provider_request_timeout(agent.provider, agent.model), - ) agent._is_anthropic_oauth = rt["is_anthropic_oauth"] + # Anthropic-on-Vertex uses the shared ``vertex`` provider — the + # dispatch to AnthropicVertex vs. the OpenAI-compat Gemini path is + # decided at runtime-resolution time by ``is_anthropic_vertex_model``. + # Inside this ``anthropic_messages`` branch, ``provider=="vertex"`` + # is unambiguous: it means Claude-on-Vertex. + if agent.provider == "vertex": + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + agent._vertex_project_id = rt.get("vertex_project_id") + agent._vertex_region = rt.get("vertex_region") or "global" + agent._anthropic_client = build_anthropic_vertex_client( + agent._vertex_project_id, agent._vertex_region, + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) + else: + from agent.anthropic_adapter import build_anthropic_client + agent._anthropic_client = build_anthropic_client( + rt["anthropic_api_key"], rt["anthropic_base_url"], + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) agent.client = None elif (agent.provider or "").strip().lower() == "moa": # MoA is a virtual provider with empty client_kwargs — rebuilding @@ -1789,14 +1803,31 @@ 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 + # ``build_anthropic_client`` is imported inside the non-Vertex + # branch below rather than here, so the Vertex path never pulls in + # the direct-Anthropic adapter. agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] - agent._anthropic_client = build_anthropic_client( - rt["anthropic_api_key"], rt["anthropic_base_url"], - timeout=get_provider_request_timeout(agent.provider, agent.model), - ) agent._is_anthropic_oauth = rt["is_anthropic_oauth"] + # Anthropic-on-Vertex uses the shared ``vertex`` provider — the + # dispatch to AnthropicVertex vs. the OpenAI-compat Gemini path is + # decided at runtime-resolution time by ``is_anthropic_vertex_model``. + # Inside this ``anthropic_messages`` branch, ``provider=="vertex"`` + # is unambiguous: it means Claude-on-Vertex. + if agent.provider == "vertex": + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + agent._vertex_project_id = rt.get("vertex_project_id") + agent._vertex_region = rt.get("vertex_region") or "global" + agent._anthropic_client = build_anthropic_vertex_client( + agent._vertex_project_id, agent._vertex_region, + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) + else: + from agent.anthropic_adapter import build_anthropic_client + agent._anthropic_client = build_anthropic_client( + rt["anthropic_api_key"], rt["anthropic_base_url"], + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) agent.client = None else: agent.client = agent._create_openai_client( @@ -3061,11 +3092,26 @@ def _restore_snapshot() -> None: # provider genuinely has none. Re-selecting the SAME provider with # an empty base_url (e.g. a credential-only refresh) is still fine # to keep the current URL. See #47828. + # + # Exception: the cloud partner-model SDKs genuinely have no base_url. + # ``AnthropicVertex`` and ``AnthropicBedrock`` derive their endpoint + # from project/region internally + # (``…/publishers/anthropic/models/:rawPredict``), so + # ``switch_model()`` correctly resolves an empty base_url for them and + # the guard's premise — "empty means resolution failed" — does not + # hold. Raising here would make ``/model anthropic/claude-*`` on + # ``provider: vertex`` unusable. Nothing is inherited in this case + # either: the branch below leaves ``agent.base_url`` untouched, and + # the Anthropic client is rebuilt from project/region rather than from + # ``agent.base_url``. old_norm_provider = (old_provider or "").strip().lower() new_norm_provider = (new_provider or "").strip().lower() + cloud_sdk_derives_endpoint = (api_mode or "") == "anthropic_messages" and ( + new_norm_provider in {"vertex", "bedrock"} + ) if base_url: agent.base_url = base_url - elif old_norm_provider != new_norm_provider: + elif old_norm_provider != new_norm_provider and not cloud_sdk_derives_endpoint: raise ValueError( f"switch_model: no base_url resolved for provider " f"'{new_provider}' (switching from '{old_provider}'); " @@ -3123,42 +3169,127 @@ def _restore_snapshot() -> None: agent._client_kwargs = {} agent.client = build_moa_facade(agent, agent.model) elif api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - _is_oauth_token, - ) - # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. - # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own - # API key — falling back would send Anthropic credentials to third-party endpoints. - _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 "") - - # 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. - if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: - try: - from hermes_cli.auth import build_minimax_oauth_token_provider - effective_key = build_minimax_oauth_token_provider() - except Exception as _mm_exc: # noqa: BLE001 - import logging as _logging - _logging.getLogger(__name__).warning( - "MiniMax OAuth: failed to install per-request token provider " - "on switch (%s); using static bearer.", - _mm_exc, - ) + # Anthropic Claude on Vertex → construct via the AnthropicVertex + # SDK, not the native Anthropic client. Unambiguous inside this + # anthropic_messages guard: ``new_provider == "vertex"`` here + # can only mean Claude-on-Vertex, because Gemini-on-Vertex uses + # chat_completions (the else-branch below). Mirrors the parallel + # branches in ``try_recover_primary_transport`` and + # ``restore_primary_runtime`` above so a Gemini-on-Vertex → + # Claude-on-Vertex ``/model`` switch mid-conversation builds + # the right client. Fixes an upstream review-caught gap where + # this construction site was unconditionally using + # ``build_anthropic_client``, leaving mid-session Vertex Claude + # switches to hit the native Anthropic endpoint with no API + # key. + if new_provider == "vertex": + from agent.anthropic_vertex_adapter import ( + build_anthropic_vertex_client, + get_anthropic_vertex_config, + ) + # Resolve project/region freshly. ``switch_model`` has no + # runtime dict to consume from (unlike the restore paths + # that get one from ``_primary_runtime``), and the agent + # may have started on a non-Vertex provider whose + # ``_vertex_project_id``/``_vertex_region`` attrs aren't + # yet set. The resolver reuses the shared vertex-adapter + # credential chain, so a session already on Gemini-on- + # Vertex sees the same config surface without a re-read. + project_id, region = get_anthropic_vertex_config() + agent._vertex_project_id = project_id + agent._vertex_region = region or "global" + # Placeholder — the AnthropicVertex SDK mints its own + # bearer tokens per request from google-auth; nothing + # observable goes on the wire under this key. Kept + # non-empty so downstream code that treats ``api_key`` + # presence as "auth resolved" continues to work. + agent.api_key = "vertex-adc" + agent._anthropic_api_key = "vertex-adc" + agent._anthropic_base_url = getattr(agent, "_anthropic_base_url", None) + agent._anthropic_client = build_anthropic_vertex_client( + agent._vertex_project_id, agent._vertex_region, + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) + # ``_is_anthropic_oauth`` is the Anthropic-native OAuth + # bearer state — irrelevant on the Vertex path where auth + # is Google-side and handled internally by AnthropicVertex. + agent._is_anthropic_oauth = False + agent.client = None + agent._client_kwargs = {} + elif new_provider == "bedrock": + # Same bug class as the Vertex branch above, and predates it: + # Bedrock-hosted Claude also speaks Anthropic Messages but + # authenticates through the AWS SDK, against a base_url with + # no ``/v1/messages`` route. ``agent_init``, + # ``run_agent._rebuild_anthropic_client`` and + # ``run_agent._create_request_anthropic_client`` all dispatch + # on ``provider == "bedrock"``; this site did not, so a + # ``/model`` switch on Bedrock 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. + from agent.anthropic_adapter import build_anthropic_bedrock_client + agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None) + # Prefer the region named by the endpoint we are switching TO; + # fall back to the region stashed at init, then AWS's default. + # Mirrors the regex agent_init.py runs over the resolved + # base_url. + _region_match = re.search( + r"bedrock-runtime\.([a-z0-9-]+)\.", agent._anthropic_base_url or "" + ) + _br_region = ( + _region_match.group(1) + if _region_match + else (getattr(agent, "_bedrock_region", None) or "us-east-1") + ) + agent._bedrock_region = _br_region + # Placeholder, same rationale as the vertex branch: the + # AnthropicBedrock SDK signs requests via the AWS credential + # chain, so no Anthropic API key goes on the wire. + agent.api_key = "aws-sdk" + agent._anthropic_api_key = "aws-sdk" + agent._anthropic_client = build_anthropic_bedrock_client(_br_region) + agent._is_anthropic_oauth = False + agent.client = None + agent._client_kwargs = {} + else: + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + _is_oauth_token, + ) + # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. + # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own + # API key — falling back would send Anthropic credentials to third-party endpoints. + _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 "") + + # 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. + if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: + try: + from hermes_cli.auth import build_minimax_oauth_token_provider + effective_key = build_minimax_oauth_token_provider() + except Exception as _mm_exc: # noqa: BLE001 + import logging as _logging + _logging.getLogger(__name__).warning( + "MiniMax OAuth: failed to install per-request token provider " + "on switch (%s); using static bearer.", + _mm_exc, + ) - 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( - effective_key, agent._anthropic_base_url, - timeout=get_provider_request_timeout(agent.provider, agent.model), - ) - agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False - agent.client = None - agent._client_kwargs = {} + 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( + effective_key, agent._anthropic_base_url, + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False + agent.client = None + agent._client_kwargs = {} else: effective_key = api_key or agent.api_key effective_base = base_url or agent.base_url @@ -3374,6 +3505,15 @@ def _restore_snapshot() -> None: "anthropic_base_url": agent._anthropic_base_url, "is_anthropic_oauth": agent._is_anthropic_oauth, }) + # Anthropic-on-Vertex: stash project + region so restore/rebuild + # can reconstruct AnthropicVertex without re-reading config.yaml. + # Guarded above by ``api_mode == "anthropic_messages"``, so + # ``provider == "vertex"`` here can only mean Claude-on-Vertex. + if agent.provider == "vertex": + agent._primary_runtime.update({ + "vertex_project_id": getattr(agent, "_vertex_project_id", None), + "vertex_region": getattr(agent, "_vertex_region", None), + }) # ── Reset fallback state ── agent._fallback_activated = False diff --git a/agent/anthropic_vertex_adapter.py b/agent/anthropic_vertex_adapter.py new file mode 100644 index 000000000000..5ddbac69c4fe --- /dev/null +++ b/agent/anthropic_vertex_adapter.py @@ -0,0 +1,297 @@ +"""Anthropic-on-Vertex adapter for Hermes Agent. + +Constructs an ``anthropic.AnthropicVertex`` client using ADC-minted OAuth +tokens and Vertex-hosted Anthropic Claude model endpoints. Mirrors +:func:`agent.anthropic_adapter.build_anthropic_bedrock_client` in shape: +the Anthropic SDK ships a purpose-built ``AnthropicVertex`` class that +handles the URL construction (``publishers/anthropic/models/: +rawPredict``) and the ``Authorization: Bearer `` header +attachment natively — we just pass ``project_id`` + ``region`` + a +short-lived Google credentials object and the SDK does the rest. + +Auth flows through :mod:`agent.vertex_adapter` — the same code path that +Gemini-on-Vertex uses. Everything the operator has to configure is +already there: + +* ``GOOGLE_APPLICATION_CREDENTIALS`` / ``VERTEX_CREDENTIALS_PATH`` for a + service-account JSON. +* Application Default Credentials via ``gcloud auth application-default + login`` or the GCE metadata server (VM SA). +* ``VERTEX_PROJECT_ID`` env var or ``vertex.project_id`` in + ``config.yaml`` to override the credentials' embedded project. +* ``VERTEX_REGION`` env var or ``vertex.region`` in ``config.yaml`` + (defaults to ``global``). + +The two Vertex code paths — Gemini via OpenAI-compat endpoint, and +Anthropic Claude via native Anthropic Messages API — share credentials, +project/region config, and OAuth token cache. Adding this provider does +not introduce a second authentication surface. + +Requires: ``pip install 'anthropic>=0.39.0'`` (for +``anthropic.AnthropicVertex``) plus ``google-auth``. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional, Tuple + +from agent.vertex_adapter import ( + DEFAULT_REGION, + _resolve_credentials_path, + _resolve_project_override, + _resolve_region, + google, +) + +logger = logging.getLogger(__name__) + + +def _get_anthropic_sdk(): + """Return the ``anthropic`` SDK module, importing lazily. + + Delegates to :mod:`agent.anthropic_adapter` so the SDK is imported at + most once per process regardless of which adapter first triggers it. + Returns ``None`` when the SDK is not installed (e.g. minimal install + without the ``anthropic`` extra) — the callers surface a friendly + ImportError with the install command. + """ + from agent.anthropic_adapter import _get_anthropic_sdk as _get_from_adapter + + return _get_from_adapter() + + +def _resolve_google_credentials(): + """Return a ``google.auth.credentials.Credentials`` for Vertex Anthropic. + + Mirrors :func:`agent.vertex_adapter.get_vertex_credentials` up to the + credentials-object step — but returns the credentials directly rather + than a materialized access token. AnthropicVertex refreshes the token + itself via the Google auth transport on each call, so handing it the + Credentials object gives it the same short-lived-token guarantees as + Gemini-on-Vertex while removing the token-plumbing complexity from + this module. + + Returns ``(creds, project_id)`` or ``(None, None)`` on failure. + """ + if google is None: + logger.warning( + "google-auth package not installed. Cannot use Anthropic on Vertex." + ) + return None, None + + from google.oauth2 import service_account + + resolved_path = _resolve_credentials_path(None) + + try: + if resolved_path: + creds = service_account.Credentials.from_service_account_file( + resolved_path, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + project_id = creds.project_id + else: + creds, project_id = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + + override_project = _resolve_project_override() + if override_project: + project_id = override_project + + return creds, project_id + except Exception as exc: + logger.error("Failed to resolve Anthropic-Vertex credentials: %s", exc) + return None, None + + +def get_anthropic_vertex_config( + region: Optional[str] = None, +) -> Tuple[Optional[str], Optional[str]]: + """Return ``(project_id, region)`` for the Anthropic-on-Vertex call. + + Does NOT include an access token — the token minting happens inside + the AnthropicVertex client via the Credentials object we hand it in + :func:`build_anthropic_vertex_client`. This function exists so + :mod:`hermes_cli.runtime_provider` can compute the display base URL + and stage the routing dict without touching the SDK. + + Returns ``(None, None)`` when credentials cannot be resolved (missing + google-auth, no ADC, no service-account JSON, etc.). + """ + _creds, project_id = _resolve_google_credentials() + if not project_id: + return None, None + return project_id, _resolve_region(region) + + +def build_anthropic_vertex_base_url( + project_id: str, region: str = DEFAULT_REGION +) -> str: + """Build a display-only base URL for the Anthropic-on-Vertex endpoint. + + Not consumed by the AnthropicVertex SDK (which builds its own URLs + from project_id + region), but hermes-agent's runtime dict and + provider auto-detection paths key off ``base_url`` — a URL shape + that matches Vertex's actual publisher-model routes keeps the + diagnostics honest and lets the ``aiplatform.googleapis.com`` host + heuristic in :mod:`agent.usage_pricing` recognize the endpoint for + billing attribution. + + The ``global`` location uses the bare ``aiplatform.googleapis.com`` + host; regional locations use ``{region}-aiplatform.googleapis.com``. + Path is ``/v1/projects/{project}/locations/{region}/publishers/ + anthropic`` — one level above the ``:rawPredict`` endpoints the SDK + actually hits, so log lines quoting the base URL point at "Anthropic + on Vertex, this project, this region" without leaking the specific + model name. + """ + host = ( + "aiplatform.googleapis.com" + if region == "global" + else f"{region}-aiplatform.googleapis.com" + ) + return ( + f"https://{host}/v1/projects/{project_id}" + f"/locations/{region}/publishers/anthropic" + ) + + +def build_anthropic_vertex_client( + project_id: str, + region: str = DEFAULT_REGION, + timeout: Optional[float] = None, +) -> Any: + """Create an ``AnthropicVertex`` client for Claude models on Vertex AI. + + Uses the Anthropic SDK's native ``AnthropicVertex`` adapter, which + provides full Claude feature parity: prompt caching, thinking budgets, + adaptive thinking, fast mode — the same set Bedrock-hosted Claude + gets. The SDK constructs the correct + ``.../publishers/anthropic/models/:rawPredict`` URL and + attaches ``Authorization: Bearer `` per request; we + only supply project_id, region, and a Google credentials object. + + Attaches the common Anthropic beta headers as client-level defaults + so Vertex-hosted Claude models get the same enhanced features as + native Anthropic (prompt caching, fine-grained tool streaming, + interleaved thinking). Does NOT attach ``context-1m-2025-08-07``. + Anthropic's March-2026 GA rollout made 1M context automatic on + Vertex-hosted Opus 4.6+ / Sonnet 4.6+ — the beta header is accepted + but ignored on that wire (see + https://claude.com/blog/1m-context-ga). Sending a no-op header is + misleading, so we omit it here; the 1M window is available with no + per-call configuration on the Vertex path. Operators who want it + attached for parity with a mixed native-Anthropic backend can pass + ``default_headers={"anthropic-beta": "context-1m-2025-08-07,..."}`` + at construction — same effect either way on Vertex, harmless. + + Auth uses the Google credentials chain: service-account JSON via + ``GOOGLE_APPLICATION_CREDENTIALS`` / ``VERTEX_CREDENTIALS_PATH``, + Application Default Credentials via ``gcloud auth + application-default login``, or the GCE metadata server on Google + Cloud VMs. Refresh happens automatically inside the SDK. + + Raises ``ImportError`` when the ``anthropic`` package is not + installed or is too old (``AnthropicVertex`` was added in 0.30.0). + """ + _anthropic_sdk = _get_anthropic_sdk() + if _anthropic_sdk is None: + raise ImportError( + "The 'anthropic' package is required for the Anthropic-on-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'" + ) + + creds, resolved_project_id = _resolve_google_credentials() + if creds is None or resolved_project_id is None: + raise RuntimeError( + "Anthropic-on-Vertex credentials could not be resolved. Vertex " + "uses OAuth2 (not a static API key): provide a service-account " + "JSON via GOOGLE_APPLICATION_CREDENTIALS (or " + "VERTEX_CREDENTIALS_PATH) in ~/.hermes/.env, or run 'gcloud auth " + "application-default login' for ADC. Set the GCP project under " + "vertex.project_id in config.yaml if it isn't embedded in the " + "credentials." + ) + + # Explicit ``project_id`` from the caller wins over the credentials' + # embedded project. Matches the ``vertex_adapter._resolve_project_override`` + # precedence used by Gemini-on-Vertex. + final_project_id = project_id or resolved_project_id + + from httpx import Timeout + + from agent.anthropic_adapter import _COMMON_BETAS + + read_timeout = ( + timeout + if (isinstance(timeout, (int, float)) and timeout > 0) + else 900.0 + ) + + return _anthropic_sdk.AnthropicVertex( + project_id=final_project_id, + region=region, + credentials=creds, + timeout=Timeout(timeout=float(read_timeout), connect=10.0), + # Delegate retry to hermes's outer loop (honors Retry-After); the SDK + # default max_retries=2 ignores it and double-retries. Matches Bedrock. + max_retries=0, + default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)}, + ) + + +def has_anthropic_vertex_credentials() -> bool: + """Fast check for whether Anthropic-on-Vertex credentials are configured. + + No network calls, no SDK import — safe for provider auto-detection + and setup-status display. True when either a service-account JSON + path is resolvable, or an explicit project ID is configured (env or + config.yaml, implying ADC is intended). + """ + if _resolve_credentials_path(None): + return True + if _resolve_project_override(): + return True + return False + + +def is_anthropic_vertex_model(model_id: str) -> bool: + """Return True if a Vertex model ID should route via the AnthropicVertex SDK. + + Used by :mod:`hermes_cli.runtime_provider` to dispatch the shared + ``vertex`` provider onto the correct wire protocol at runtime: + + * ``anthropic/…`` → ``anthropic_messages`` mode via the + ``AnthropicVertex`` SDK (this classifier). + * everything else (``google/gemini-*``, and any future partner + family Vertex Model Garden adds) → ``chat_completions`` mode via + Vertex's OpenAI-compat aggregator. + + The vendor prefix is **required**. Contrast with + :func:`agent.bedrock_adapter.is_anthropic_bedrock_model`, which + matches ``anthropic.claude`` (and the regional variants) — Bedrock + grew up as an Anthropic-only surface and its bare-``claude-*`` + acceptance is a legacy shortcut. Vertex Model Garden is + multi-vendor from day one (Anthropic + Google + more coming), so + the classifier accepts *only* the fully-qualified + ``anthropic/`` form. This surfaces a loud, actionable error + when someone writes ``default: "claude-opus-4-8"`` under + ``provider: vertex``: the request goes down the aggregator path + and Vertex 404s with "publisher google — model claude-opus-4-8 not + found", telling the user exactly what to fix. + + Case-insensitive. Whitespace is stripped. + """ + if not isinstance(model_id, str): + return False + m = model_id.strip().lower() + if not m: + return False + return m.startswith("anthropic/") diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 7ddb60f7c67a..4fefd002469c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -7048,12 +7048,55 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", pconfig = PROVIDER_REGISTRY.get(provider) if pconfig is None: - # Demoted from logger.warning to debug; dedup keyed by provider name - # so the first occurrence surfaces but repeated retries stay silent. - if provider not in _LOGGED_UNKNOWN_PROVIDER_KEYS: - _LOGGED_UNKNOWN_PROVIDER_KEYS.add(provider) - logger.debug("resolve_provider_client: unknown provider %r", provider) - return None, None + # Plugin-catalog fallback for non-api_key providers. + # + # ``hermes_cli/auth.py`` auto-extends ``PROVIDER_REGISTRY`` from the + # provider-plugin catalog, but its filter (``auth_type != "api_key" + # or not env_vars``) intentionally skips OAuth-token / SDK-token + # providers (vertex, aws_sdk, oauth_device_code, oauth_external). + # Without this fallback, ``resolve_provider_client("vertex", ...)`` + # returns ``(None, None)`` and the existing + # ``elif pconfig.auth_type == "vertex":`` branch below is dead code + # — silently breaking every auxiliary task (vision, compression, + # curator, session_search, title generation) on any deployment + # whose main provider is a non-api_key one. The failure is latent + # for fleets that keep an aggregator fallback (openrouter/nous): + # Step 3 of ``_resolve_auto`` catches the miss. A vertex-only + # deployment (no aggregator credentials) hits it terminally with + # ``RuntimeError: No LLM provider configured for task=``. + # + # Consult the plugin catalog directly for the well-known non-api_key + # auth families and synthesize a minimal ``pconfig``-shaped object + # so the existing auth_type dispatch further down still fires. The + # dispatch is the single source of truth for how each family builds + # a client; we only fix the reachability. + try: + from providers import get_provider_profile as _get_provider_profile + except ImportError: + _get_provider_profile = None + + _plugin_profile = None + if _get_provider_profile is not None: + try: + _plugin_profile = _get_provider_profile(provider) + except Exception: + _plugin_profile = None + + _NON_API_KEY_AUTH_TYPES = { + "vertex", "aws_sdk", "oauth_device_code", "oauth_external", + } + if ( + _plugin_profile is not None + and _plugin_profile.auth_type in _NON_API_KEY_AUTH_TYPES + ): + pconfig = SimpleNamespace(auth_type=_plugin_profile.auth_type) + else: + # Demoted from logger.warning to debug; dedup keyed by provider name + # so the first occurrence surfaces but repeated retries stay silent. + if provider not in _LOGGED_UNKNOWN_PROVIDER_KEYS: + _LOGGED_UNKNOWN_PROVIDER_KEYS.add(provider) + logger.debug("resolve_provider_client: unknown provider %r", provider) + return None, None if pconfig.auth_type == "api_key": if provider == "anthropic": @@ -7233,10 +7276,90 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", return None, None elif pconfig.auth_type == "vertex": - # Google Vertex AI — Gemini via the OpenAI-compatible endpoint with an - # OAuth2 bearer token (NOT a static key). We build a standard OpenAI - # client pointed at the runtime-computed Vertex base_url with a fresh - # token; no custom SDK or message translation needed. + # Vertex Model Garden hosts multiple publisher families through one + # provider name: Google's Gemini via the OpenAI-compat aggregator, + # Anthropic's Claude via native Messages at ``publishers/anthropic/ + # models/*:rawPredict``. Wire protocol is chosen by model prefix. + # Mirrors ``hermes_cli/runtime_provider.py``'s main-agent dispatch + # so auxiliary tasks (vision, compression, curator, session_search) + # route the same way as the top-level turn. + try: + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + except ImportError: + def is_anthropic_vertex_model(_m: str) -> bool: + return False + + # ``is_anthropic_vertex_model`` intentionally requires the fully- + # qualified ``anthropic/`` form so mistakes in main-agent + # config surface as a loud Vertex 404 (see the classifier's + # docstring for why). Auxiliary callers see the model AFTER + # ``agent_init.py::normalize_model_for_provider`` has stripped the + # prefix — set_runtime_main then stores the bare form + # (``claude-opus-4-8``) as the "runtime main model", and vision / + # compression / title generation read that bare form via + # ``_read_main_model()``. Widen detection here to also match bare + # ``claude-*`` (case-insensitive) so aux dispatch stays correct + # across both forms, without touching the strict classifier the + # main-agent path relies on. Mirrors bedrock's dual accept of + # ``anthropic.claude-*`` and bare ``claude-*``. + _model_lc = (model or "").strip().lower() + _is_anthropic = ( + is_anthropic_vertex_model(model) + or _model_lc.startswith("claude-") + ) + + if _is_anthropic: + # Claude on Vertex → AnthropicVertex SDK (Anthropic Messages + # wire). Same shape as the aws_sdk branch's Bedrock Anthropic + # path — build a real Anthropic client, wrap it in the + # OpenAI-compatible AnthropicAuxiliaryClient shim. + try: + from agent.anthropic_vertex_adapter import ( + build_anthropic_vertex_base_url, + build_anthropic_vertex_client, + get_anthropic_vertex_config, + has_anthropic_vertex_credentials, + ) + except ImportError: + logger.warning("resolve_provider_client: vertex-anthropic " + "requested but the anthropic SDK / google-auth " + "is not installed") + return None, None + + if not has_anthropic_vertex_credentials(): + logger.debug("resolve_provider_client: vertex-anthropic " + "requested but no GCP credentials found") + return None, None + + project_id, region = get_anthropic_vertex_config() + if not project_id: + logger.warning("resolve_provider_client: vertex-anthropic " + "requested but project_id resolution failed") + return None, None + + final_model = _normalize_resolved_model(model, provider) + try: + real_client = build_anthropic_vertex_client(project_id, region) + except (ImportError, RuntimeError) as exc: + logger.warning("resolve_provider_client: cannot create " + "AnthropicVertex client: %s", exc) + return None, None + + display_base_url = build_anthropic_vertex_base_url(project_id, region) + client = AnthropicAuxiliaryClient( + real_client, final_model, api_key="vertex-adc", + base_url=display_base_url, + ) + logger.debug("resolve_provider_client: vertex anthropic (%s, %s/%s)", + final_model, project_id, region) + return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + else (client, final_model)) + + # Gemini on Vertex (default when no explicit model, or a + # ``google/...`` slug) — OpenAI-compat aggregator with an OAuth2 + # bearer token (NOT a static key). We build a standard OpenAI + # client pointed at the runtime-computed Vertex base_url with a + # fresh token; no custom SDK or message translation needed. try: from agent.vertex_adapter import get_vertex_config, has_vertex_credentials except ImportError: @@ -7268,7 +7391,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", logger.warning("resolve_provider_client: cannot create Vertex " "client: %s", exc) return None, None - logger.debug("resolve_provider_client: vertex (%s)", final_model) + logger.debug("resolve_provider_client: vertex gemini (%s)", final_model) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 21d10c5e6fbd..90965d02a638 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -550,6 +550,29 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: return name.split("/", 1)[1] return stripped + # --- Vertex: mixed-family aggregator. Vertex hosts Google's Gemini + # via its OpenAI-compat aggregator endpoint AND Anthropic's Claude + # via the Anthropic Messages endpoint. Each family has a distinct + # wire-model-name convention: + # + # * Gemini via OpenAI-compat wants ``google/gemini-*`` (the + # vendor prefix is *required* on that endpoint), so we pass it + # through unchanged. + # * Anthropic via ``AnthropicVertex`` builds its URL as + # ``publishers/anthropic/models/{model}:rawPredict`` by + # substituting the ``model`` field verbatim from the JSON body. + # A leading ``anthropic/`` would corrupt the URL — so we strip + # it here. + # + # Bare model names (``claude-opus-4-8``, ``gemini-3.1-pro-preview``) + # pass through unchanged too — users who copy names without the + # vendor prefix keep working on the Anthropic side, and the Gemini + # side will surface a clear 404 telling them to add the prefix. + if provider == "vertex": + if name.lower().startswith("anthropic/"): + return name.split("/", 1)[1] + return name + # --- DeepSeek: map to one of two canonical names --- if provider == "deepseek": bare = _strip_matching_provider_prefix(name, provider) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index fb4a4dd83adc..687de03cbfe3 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1324,7 +1324,7 @@ class ProviderEntry(NamedTuple): ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"), ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"), ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"), - ProviderEntry("vertex", "Google Vertex AI", "Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)"), + ProviderEntry("vertex", "Google Vertex AI", "Google Vertex AI (Gemini + Anthropic Claude via GCP; OAuth2 service account or ADC, GCP billing/quotas)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"), diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 03bb777e9035..bede4c8dd757 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1969,8 +1969,77 @@ def resolve_runtime_provider( # margin) by get_vertex_config(); mid-session expiry is additionally # recovered on 401 by run_agent._try_refresh_vertex_client_credentials(). if requested_provider in ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"): + from agent.anthropic_vertex_adapter import ( + build_anthropic_vertex_base_url, + get_anthropic_vertex_config, + has_anthropic_vertex_credentials, + is_anthropic_vertex_model, + ) from agent.vertex_adapter import get_vertex_config + # Vertex Model Garden hosts models from multiple publishers behind + # one ``roles/aiplatform.user`` ADC surface: Google's own Gemini + # family and Anthropic's Claude family (with more partner families + # likely to follow). Each family has its own wire protocol on + # Vertex — Gemini via the OpenAI-compat aggregator, Anthropic via + # its native Messages API at ``publishers/anthropic/models/*: + # rawPredict`` — but the auth path, project/region config, and + # billing are all shared. Mirrors bedrock's dual-path dispatch + # (see below): one provider name, model-name-driven transport + # selection. + # + # Local name is ``_vertex_model_cfg`` (not ``model_cfg``) on + # purpose: ``model_cfg`` is assigned further down inside the + # ``if not explicit_base_url and not explicit_api_key:`` branch + # of this same function, and Python's static scoping makes the + # name local to the whole function once ANY assignment to it + # appears. Referencing a bare ``model_cfg`` here would raise + # UnboundLocalError on the call paths that skip target_model + # (cron scheduler + gateway per-turn agent resolve). + _vertex_model_cfg = _get_model_config() + _model_default = str(target_model or _vertex_model_cfg.get("default") or "").strip() + + if is_anthropic_vertex_model(_model_default): + # Claude on Vertex → AnthropicVertex SDK → anthropic_messages path + if not has_anthropic_vertex_credentials(): + raise AuthError( + "Anthropic on Vertex AI credentials could not be resolved. " + "Vertex uses OAuth2 (not a static API key): provide a " + "service-account JSON via GOOGLE_APPLICATION_CREDENTIALS " + "(or VERTEX_CREDENTIALS_PATH) in ~/.hermes/.env, or run " + "'gcloud auth application-default login' for ADC. Set the " + "GCP project/region under vertex: in config.yaml if they " + "aren't embedded in the credentials. Anthropic models must " + "ALSO be enabled in the Vertex Model Garden for your " + "project (a one-time console click per model to accept " + "Anthropic's TOS and start the Marketplace subscription)." + ) + project_id, region = get_anthropic_vertex_config() + if not project_id: + raise AuthError( + "Anthropic on Vertex AI: project_id resolution failed. " + "Set VERTEX_PROJECT_ID (env) or vertex.project_id " + "(config.yaml), or provide credentials with an embedded " + "project_id." + ) + return { + "provider": "vertex", + "api_mode": "anthropic_messages", + "base_url": build_anthropic_vertex_base_url(project_id, region), + # Opaque placeholder — the AnthropicVertex SDK mints its own + # tokens from the credentials chain. Never goes on the wire + # but must be non-empty for downstream code that treats + # ``api_key`` presence as "auth resolved". + "api_key": "vertex-adc", + "anthropic_api_key": "vertex-adc", + "source": "vertex-anthropic-oauth", + "vertex_project_id": project_id, + "vertex_region": region, + "vertex_anthropic": True, # Signal for client-construction sites + "requested_provider": requested_provider, + } + + # Gemini on Vertex → OpenAI-compat aggregator → chat_completions path token, base_url = get_vertex_config() if not token or not base_url: raise AuthError( diff --git a/run_agent.py b/run_agent.py index 60301d5cbbf6..773181f0fee6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5609,10 +5609,29 @@ def _request_anthropic_client_cache_ref(self) -> dict: def _request_anthropic_client_key(self) -> tuple: """Cache key covering everything that forces a fresh client: credential rotation, base URL / region changes, timeout changes (model switch), - and the 1M-context beta flag.""" - if getattr(self, "provider", None) == "bedrock": + and the 1M-context beta flag. + + ``key[0]`` is the provider discriminator that + ``_create_request_anthropic_client`` dispatches on, so every provider + that needs a non-default SDK MUST have a branch here as well as there. + A provider missing from this function silently keys as ``"direct"`` and + gets a direct-Anthropic client pointed at a non-Anthropic base_url. + """ + _provider = getattr(self, "provider", None) + if _provider == "bedrock": region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1" return ("bedrock", region) + if _provider == "vertex": + # Claude-on-Vertex. The timeout belongs in the key because + # ``build_anthropic_vertex_client`` bakes it into the client (a + # ``/model`` switch changes it); project and region because they + # decide the publisher route. No API key — ADC, not a bearer. + return ( + "vertex", + getattr(self, "_vertex_project_id", None), + getattr(self, "_vertex_region", None) or "global", + get_provider_request_timeout(self.provider, self.model), + ) return ( "direct", self._anthropic_api_key, @@ -5642,9 +5661,14 @@ def _create_request_anthropic_client(self, *, reason: str) -> Any: keeps a second concurrent call from sharing one pool's close/abort lifecycle — it gets a fresh untracked client instead. - Mirrors ``_rebuild_anthropic_client`` construction (direct + Bedrock, - 1M-beta drop) but returns a fresh/cached client instead of swapping - the shared one. + Mirrors ``_rebuild_anthropic_client`` construction (direct + Bedrock + + Vertex, 1M-beta drop) but returns a fresh/cached client instead of + swapping the shared one. The provider dispatch MUST stay in sync with + ``_rebuild_anthropic_client`` AND with + ``_request_anthropic_client_key``: this client is what actually carries + every in-flight request, so a provider that is special-cased there but + not here silently regresses to a direct-Anthropic client pointed at a + non-Anthropic base_url. """ if self.api_mode == "anthropic_messages": self._try_refresh_anthropic_client_credentials() @@ -5677,6 +5701,24 @@ def _create_request_anthropic_client(self, *, reason: str) -> Any: if key[0] == "bedrock": from agent.anthropic_adapter import build_anthropic_bedrock_client client = build_anthropic_bedrock_client(key[1]) + elif key[0] == "vertex": + # Claude-on-Vertex — same dispatch as ``_rebuild_anthropic_client``. + # Only reachable when api_mode resolved to anthropic_messages + # (Gemini on Vertex uses chat_completions and never builds an + # Anthropic client). Project + region were stashed on the agent + # during init from the runtime dict and travel in the cache key. + # + # Without this branch the ``else`` below builds a direct Anthropic + # client whose base_url is the display-only Vertex publisher URL, + # so the SDK POSTs to ``…/publishers/anthropic/v1/messages`` + # instead of ``…/publishers/anthropic/models/:rawPredict`` + # and every call 404s. + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + client = build_anthropic_vertex_client( + key[1], + key[2], + timeout=key[3], + ) else: from agent.anthropic_adapter import build_anthropic_client client = build_anthropic_client( @@ -6595,10 +6637,24 @@ def _rebuild_anthropic_client(self) -> None: rebuilt client carries the reduced beta set. """ _drop_1m = bool(getattr(self, "_oauth_1m_beta_disabled", False)) - if getattr(self, "provider", None) == "bedrock": + _provider = getattr(self, "provider", None) + if _provider == "bedrock": from agent.anthropic_adapter import build_anthropic_bedrock_client region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1" self._anthropic_client = build_anthropic_bedrock_client(region) + elif _provider == "vertex": + # Claude-on-Vertex path — only reachable here because + # api_mode was resolved to anthropic_messages upstream (Gemini + # on Vertex uses chat_completions and never rebuilds an + # anthropic client). Project + region were stashed on the + # agent during init from the runtime dict. + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + project_id = getattr(self, "_vertex_project_id", None) + region = getattr(self, "_vertex_region", None) or "global" + self._anthropic_client = build_anthropic_vertex_client( + project_id, region, + timeout=get_provider_request_timeout(self.provider, self.model), + ) else: from agent.anthropic_adapter import build_anthropic_client self._anthropic_client = build_anthropic_client( diff --git a/tests/agent/test_anthropic_vertex_adapter.py b/tests/agent/test_anthropic_vertex_adapter.py new file mode 100644 index 000000000000..105525999347 --- /dev/null +++ b/tests/agent/test_anthropic_vertex_adapter.py @@ -0,0 +1,411 @@ +"""Tests for agent/anthropic_vertex_adapter.py — Anthropic on Vertex AI.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +# Anthropic-vertex reuses the vertex adapter's credential resolution helpers. +# Tests here mock at the seam between our adapter and the google-auth / +# anthropic SDKs, so they don't hit either dependency at runtime. + + +def _reset_anthropic_sdk_cache(): + """Clear the cached ``_anthropic_sdk`` sentinel between tests. + + The adapter caches the imported SDK module (or ``None`` when the import + fails) after the first access. Tests that patch the SDK need to reset + the sentinel so each test resolves it independently. + """ + from agent import anthropic_adapter + + anthropic_adapter._anthropic_sdk = ... # sentinel + + +# --------------------------------------------------------------------------- +# Base URL builder +# --------------------------------------------------------------------------- + + +class TestBuildAnthropicVertexBaseUrl: + def test_global_uses_bare_host(self): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_base_url + + url = build_anthropic_vertex_base_url("my-proj", "global") + assert url == ( + "https://aiplatform.googleapis.com/v1/projects/my-proj" + "/locations/global/publishers/anthropic" + ) + + def test_regional_uses_prefixed_host(self): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_base_url + + url = build_anthropic_vertex_base_url("my-proj", "us-east5") + assert url == ( + "https://us-east5-aiplatform.googleapis.com/v1/projects/my-proj" + "/locations/us-east5/publishers/anthropic" + ) + + +# --------------------------------------------------------------------------- +# Credentials resolution +# --------------------------------------------------------------------------- + + +class TestResolveGoogleCredentials: + def test_missing_google_auth_returns_none(self): + """When google-auth is not installed, resolver returns (None, None).""" + with patch("agent.anthropic_vertex_adapter.google", None): + from agent.anthropic_vertex_adapter import _resolve_google_credentials + + creds, project_id = _resolve_google_credentials() + assert creds is None + assert project_id is None + + def test_adc_returns_credentials_and_project(self): + """With ADC available, resolver returns (creds, project_id).""" + mock_creds = MagicMock() + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_credentials_path", + return_value=None, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_project_override", + return_value=None, + ), + patch("agent.anthropic_vertex_adapter.google") as mock_google, + ): + mock_google.auth.default.return_value = (mock_creds, "test-project-42") + + from agent.anthropic_vertex_adapter import _resolve_google_credentials + + creds, project_id = _resolve_google_credentials() + assert creds is mock_creds + assert project_id == "test-project-42" + + def test_explicit_project_override_wins(self): + """VERTEX_PROJECT_ID env / config.yaml override wins over embedded project.""" + mock_creds = MagicMock() + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_credentials_path", + return_value=None, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_project_override", + return_value="override-project", + ), + patch("agent.anthropic_vertex_adapter.google") as mock_google, + ): + mock_google.auth.default.return_value = (mock_creds, "embedded-project") + + from agent.anthropic_vertex_adapter import _resolve_google_credentials + + _creds, project_id = _resolve_google_credentials() + assert project_id == "override-project" + + +class TestGetAnthropicVertexConfig: + def test_returns_project_and_default_region(self): + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), "test-proj"), + ), + patch( + "agent.anthropic_vertex_adapter._resolve_region", + return_value="global", + ), + ): + from agent.anthropic_vertex_adapter import get_anthropic_vertex_config + + project_id, region = get_anthropic_vertex_config() + assert project_id == "test-proj" + assert region == "global" + + def test_no_project_returns_none_none(self): + """When credentials resolve but embedded project is empty, return (None, None).""" + with patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), None), + ): + from agent.anthropic_vertex_adapter import get_anthropic_vertex_config + + project_id, region = get_anthropic_vertex_config() + assert project_id is None + assert region is None + + def test_explicit_region_argument_wins(self): + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), "test-proj"), + ), + patch( + "agent.anthropic_vertex_adapter._resolve_region", + side_effect=lambda explicit=None: explicit or "global", + ), + ): + from agent.anthropic_vertex_adapter import get_anthropic_vertex_config + + _p, region = get_anthropic_vertex_config(region="us-east5") + assert region == "us-east5" + + +# --------------------------------------------------------------------------- +# Client construction +# --------------------------------------------------------------------------- + + +class TestBuildAnthropicVertexClient: + def setup_method(self): + _reset_anthropic_sdk_cache() + + def teardown_method(self): + _reset_anthropic_sdk_cache() + + def test_missing_sdk_raises(self): + with patch("agent.anthropic_adapter._anthropic_sdk", None): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + + with pytest.raises(ImportError, match="anthropic"): + build_anthropic_vertex_client("proj", "global") + + def test_sdk_without_anthropic_vertex_raises(self): + """Older SDK versions without AnthropicVertex class fail clearly.""" + mock_sdk = MagicMock() + del mock_sdk.AnthropicVertex # attribute absent + with ( + patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), "proj"), + ), + ): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + + with pytest.raises(ImportError, match="AnthropicVertex not available"): + build_anthropic_vertex_client("proj", "global") + + def test_missing_credentials_raises(self): + mock_sdk = MagicMock() + mock_sdk.AnthropicVertex = MagicMock() + with ( + patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(None, None), + ), + ): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + + with pytest.raises(RuntimeError, match="credentials could not be resolved"): + build_anthropic_vertex_client("proj", "global") + + def test_client_constructed_with_expected_kwargs(self): + mock_sdk = MagicMock() + mock_sdk.AnthropicVertex = MagicMock() + mock_creds = MagicMock() + with ( + patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(mock_creds, "creds-proj"), + ), + ): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + + build_anthropic_vertex_client("explicit-proj", "us-east5", timeout=120.0) + + kwargs = mock_sdk.AnthropicVertex.call_args[1] + # Explicit project_id wins over credentials' embedded project. + assert kwargs["project_id"] == "explicit-proj" + assert kwargs["region"] == "us-east5" + assert kwargs["credentials"] is mock_creds + # Hermes disables SDK-level retries so its own outer loop can honor + # Retry-After. Same contract as bedrock. + assert kwargs["max_retries"] == 0 + # Common Anthropic beta headers are attached; context-1m is NOT + # (subscriptions without the long-context beta reject it). + betas = kwargs["default_headers"]["anthropic-beta"] + assert "interleaved-thinking-2025-05-14" in betas + assert "fine-grained-tool-streaming-2025-05-14" in betas + assert "context-1m-2025-08-07" not in betas + + def test_credentials_project_used_when_explicit_project_falsy(self): + mock_sdk = MagicMock() + mock_sdk.AnthropicVertex = MagicMock() + with ( + patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), "creds-proj"), + ), + ): + from agent.anthropic_vertex_adapter import build_anthropic_vertex_client + + build_anthropic_vertex_client("", "global") + + kwargs = mock_sdk.AnthropicVertex.call_args[1] + assert kwargs["project_id"] == "creds-proj" + + +# --------------------------------------------------------------------------- +# Fast credential-present check +# --------------------------------------------------------------------------- + + +class TestHasAnthropicVertexCredentials: + def test_service_account_path_returns_true(self): + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_credentials_path", + return_value="/tmp/sa.json", + ), + patch( + "agent.anthropic_vertex_adapter._resolve_project_override", + return_value=None, + ), + ): + from agent.anthropic_vertex_adapter import has_anthropic_vertex_credentials + + assert has_anthropic_vertex_credentials() is True + + def test_project_override_returns_true(self): + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_credentials_path", + return_value=None, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_project_override", + return_value="my-proj", + ), + ): + from agent.anthropic_vertex_adapter import has_anthropic_vertex_credentials + + assert has_anthropic_vertex_credentials() is True + + def test_no_config_returns_false(self): + with ( + patch( + "agent.anthropic_vertex_adapter._resolve_credentials_path", + return_value=None, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_project_override", + return_value=None, + ), + ): + from agent.anthropic_vertex_adapter import has_anthropic_vertex_credentials + + assert has_anthropic_vertex_credentials() is False + + +# --------------------------------------------------------------------------- +# Model classifier — dispatches ``vertex`` provider onto anthropic_messages +# --------------------------------------------------------------------------- + + +class TestIsAnthropicVertexModel: + """``is_anthropic_vertex_model`` is the runtime dispatch classifier. + + Called by ``resolve_runtime_provider`` when the requested provider + is ``vertex`` — a True return routes through the ``AnthropicVertex`` + SDK (anthropic_messages wire), a False return routes through Vertex's + OpenAI-compat aggregator (chat_completions wire, same code path as + Gemini-on-Vertex). + + Behavior contract (STRICT — no legacy shortcuts): + + * ``anthropic/`` → True. The vendor prefix is REQUIRED. + * Anything else — including bare ``claude-*`` — → False. + + The strict form is deliberate. Vertex Model Garden is multi-vendor + from day one, so a bare model name has no unambiguous meaning: + ``claude-opus-4-8`` under ``provider=vertex`` could plausibly be + misrouting a Gemini setup that accidentally shipped a Claude ID, + and the right behavior is to surface that as a Vertex 404 pointing + at the misconfiguration rather than silently guessing at the + intended wire protocol. Contrast Bedrock, which accepts bare + ``claude-*`` as a legacy shortcut from the era when Bedrock was + Anthropic-only. + """ + + @pytest.mark.parametrize( + "model_id", + [ + "anthropic/claude-opus-4-8", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-haiku-4-5", + "anthropic/claude-fable-5", + # Version-suffixed IDs — the ``@YYYYMMDD`` form Vertex also accepts. + "anthropic/claude-opus-4-5@20250929", + # Case-insensitive. + "ANTHROPIC/claude-opus-4-8", + "Anthropic/Claude-Opus-4-8", + # Whitespace-tolerant (defensive against config-file whitespace). + " anthropic/claude-opus-4-8 ", + ], + ) + def test_vendor_prefixed_anthropic_matches(self, model_id): + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + + assert is_anthropic_vertex_model(model_id) is True + + @pytest.mark.parametrize( + "model_id", + [ + # Bare Claude names must NOT match — vendor prefix is required + # (see class docstring). These will fall through to the + # OpenAI-compat aggregator and 404 with an actionable error. + "claude-opus-4-8", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "claude-fable-5", + "CLAUDE-OPUS-4-8", + ], + ) + def test_bare_claude_rejected_without_vendor_prefix(self, model_id): + """Regression guard: strict vendor-prefix requirement. + + Historically an earlier draft of the classifier accepted bare + ``claude-*`` as a convenience. That was dropped: Vertex is a + multi-vendor surface, so a bare name has no unambiguous meaning. + """ + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + + assert is_anthropic_vertex_model(model_id) is False + + @pytest.mark.parametrize( + "model_id", + [ + # Gemini on Vertex — must take the OpenAI-compat path. + "google/gemini-3.1-pro-preview", + "google/gemini-3-pro-preview", + "google/gemma-3-27b-it", + "gemini-3.1-pro-preview", + "gemini-3-pro-preview", + # OpenRouter-style Anthropic slug (different provider entirely + # — should NOT reach the vertex dispatch, but the classifier + # correctly returns False for the negative case too). + "openrouter/anthropic/claude-opus-4-8", + # Non-Claude Anthropic-ish strings we shouldn't accidentally match. + "claudius-something", + ], + ) + def test_non_anthropic_models_reject(self, model_id): + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + + assert is_anthropic_vertex_model(model_id) is False + + @pytest.mark.parametrize("value", ["", " ", None, 42, 0, [], {}]) + def test_empty_or_non_string_reject(self, value): + """Defensive: unexpected inputs must return False, not raise.""" + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + + assert is_anthropic_vertex_model(value) is False diff --git a/tests/agent/test_auxiliary_client_vertex_dispatch.py b/tests/agent/test_auxiliary_client_vertex_dispatch.py new file mode 100644 index 000000000000..b124317f366f --- /dev/null +++ b/tests/agent/test_auxiliary_client_vertex_dispatch.py @@ -0,0 +1,498 @@ +"""Tests for auxiliary-client routing of the ``vertex`` provider. + +Covers the two-part fix in ``agent.auxiliary_client.resolve_provider_client``: + + Part A — plugin-catalog fallback for non-api_key providers. + ``PROVIDER_REGISTRY`` in :mod:`hermes_cli.auth` auto-extends from the + provider-plugin catalog, but the extension's filter (``auth_type != + "api_key" or not env_vars``) intentionally excludes vertex / bedrock / + OAuth providers. Without the fallback, ``resolve_provider_client("vertex", + ...)`` short-circuits at the registry lookup with "unknown provider" and + the existing ``elif pconfig.auth_type == "vertex":`` handler below is + dead code. Every auxiliary task (vision, compression, curator, + session_search) silently breaks on ``provider: vertex`` deployments. + + Part B — Anthropic-vs-Gemini dispatch inside the vertex branch. + Vertex Model Garden hosts both Google Gemini (OpenAI-compat aggregator) + and Anthropic Claude (native Messages at + ``publishers/anthropic/models/*:rawPredict``). One provider name, model + prefix picks the wire protocol. Mirrors + :func:`hermes_cli.runtime_provider.resolve_runtime_provider`'s main-agent + dispatch so auxiliary calls behave identically. + +All tests mock the two credential seams (``has_*_credentials`` + +``get_*_config`` / ``_resolve_google_credentials``) and the SDK factories +so they run hermetically without live GCP / Anthropic dependencies. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _mock_google_credentials(): + """Return a stand-in for the ``(Credentials, project_id)`` tuple that + :func:`agent.anthropic_vertex_adapter._resolve_google_credentials` + returns on a real ADC-configured host.""" + creds = MagicMock(name="google_credentials") + creds.token = "mocked-oauth-token" + return creds, "test-project-42" + + +def _mock_anthropic_sdk(): + """Return a stand-in for the ``anthropic`` module with an + ``AnthropicVertex`` class. Instances are MagicMocks so downstream + ``real_client.messages.create(...)`` calls don't hit the wire.""" + sdk = MagicMock(name="anthropic_sdk") + sdk.AnthropicVertex = MagicMock( + return_value=MagicMock(name="AnthropicVertex_instance"), + ) + return sdk + + +# --------------------------------------------------------------------------- +# Part A — plugin-catalog fallback reaches the vertex handler +# --------------------------------------------------------------------------- + + +class TestPluginCatalogFallback: + """The whole point of the fix — ``provider="vertex"`` must reach the + vertex handler even though vertex is not in ``PROVIDER_REGISTRY``.""" + + def test_vertex_provider_reaches_dispatch_branch(self): + """Without the fallback this call returns (None, None) silently. + With the fallback it reaches the Gemini branch and constructs a + real OpenAI-compat client.""" + from agent.auxiliary_client import resolve_provider_client + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, model = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", + ) + assert client is not None, ( + "Regression: vertex fell off the registry lookup and never " + "reached the auth_type == 'vertex' handler." + ) + assert model == "google/gemini-3.1-pro-preview" + + def test_unknown_provider_still_returns_none(self): + """The fallback must not swallow genuinely-unknown providers — + a typo like ``verttex`` should still bail with (None, None) so + callers can fall back to their auto chain.""" + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client("nonexistent-fake-provider") + assert client is None + assert model is None + + def test_vertex_alias_reaches_dispatch(self): + """The vertex provider profile registers aliases (``google-vertex``, + ``vertex-ai``, ``gcp-vertex``). ``get_provider_profile`` resolves + them, so the fallback should reach the same handler for aliases.""" + from agent.auxiliary_client import resolve_provider_client + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + # Every alias resolves to the same "vertex" auth_type, so the + # fallback shim reaches the Gemini branch. + for alias in ("google-vertex", "vertex-ai", "gcp-vertex"): + client, _ = resolve_provider_client( + alias, "google/gemini-3.1-pro-preview", + ) + assert client is not None, ( + f"Alias {alias!r} did not reach the vertex dispatch " + "branch. The fallback must resolve aliases via " + "get_provider_profile, not just canonical names." + ) + + +# --------------------------------------------------------------------------- +# Part B — Anthropic-on-Vertex dispatch +# --------------------------------------------------------------------------- + + +class TestVertexAnthropicDispatch: + """Vertex + ``anthropic/`` model → AnthropicVertex SDK path.""" + + def _patched_anthropic_success(self): + """Return the context manager stack for a happy AnthropicVertex + path — credentials present, SDK importable, project resolved.""" + return ( + patch( + "agent.anthropic_vertex_adapter.has_anthropic_vertex_credentials", + return_value=True, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=_mock_google_credentials(), + ), + patch( + "agent.anthropic_vertex_adapter._get_anthropic_sdk", + return_value=_mock_anthropic_sdk(), + ), + ) + + def test_anthropic_prefix_builds_anthropic_client(self): + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, model = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", is_vision=True, + ) + assert isinstance(client, AnthropicAuxiliaryClient), ( + "Expected AnthropicAuxiliaryClient wrapping the AnthropicVertex " + f"SDK, got {type(client).__name__}." + ) + + def test_anthropic_model_prefix_stripped_in_stored_model(self): + """The AnthropicVertex SDK expects the bare model id + (``claude-opus-4-8``). ``_normalize_resolved_model`` should strip + the ``anthropic/`` prefix before we hand it to the wrapper.""" + from agent.auxiliary_client import resolve_provider_client + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + _, model = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert model == "claude-opus-4-8" + + def test_anthropic_client_carries_vertex_placeholder_api_key(self): + """``AnthropicAuxiliaryClient`` demands a non-empty ``api_key`` so + downstream code that checks ``bool(client.api_key)`` treats + Anthropic-on-Vertex as authenticated. The AnthropicVertex SDK + mints its own OAuth tokens; the ``vertex-adc`` placeholder is the + agreed sentinel (matches runtime_provider.py + agent_init.py).""" + from agent.auxiliary_client import resolve_provider_client + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, _ = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert client.api_key == "vertex-adc" + + def test_anthropic_client_base_url_reports_vertex_endpoint(self): + """The base_url on the wrapper is display-only (for logs and + billing attribution). It must reflect the actual Vertex publisher + endpoint shape so ``agent.usage_pricing``'s ``aiplatform. + googleapis.com`` heuristic and log lines quoting the base URL + both work.""" + from agent.auxiliary_client import resolve_provider_client + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, _ = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert "aiplatform.googleapis.com" in client.base_url + assert "publishers/anthropic" in client.base_url + assert "test-project-42" in client.base_url + + def test_anthropic_missing_gcp_credentials_returns_none(self): + """No ADC / service-account JSON / vertex.project_id — return + (None, None) so callers can fall through to their auto chain, + rather than raising.""" + from agent.auxiliary_client import resolve_provider_client + + with patch( + "agent.anthropic_vertex_adapter.has_anthropic_vertex_credentials", + return_value=False, + ): + client, model = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert client is None + assert model is None + + def test_anthropic_missing_project_id_returns_none(self): + """Credentials present but project resolution fails (e.g. ADC + with no embedded project + no ``vertex.project_id`` in config).""" + from agent.auxiliary_client import resolve_provider_client + + with ( + patch( + "agent.anthropic_vertex_adapter.has_anthropic_vertex_credentials", + return_value=True, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=(MagicMock(), None), + ), + ): + client, model = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert client is None + assert model is None + + def test_anthropic_sdk_missing_returns_none(self): + """anthropic package not installed (or too old to have + ``AnthropicVertex``) — return (None, None), warn, don't raise.""" + from agent.auxiliary_client import resolve_provider_client + + with ( + patch( + "agent.anthropic_vertex_adapter.has_anthropic_vertex_credentials", + return_value=True, + ), + patch( + "agent.anthropic_vertex_adapter._resolve_google_credentials", + return_value=_mock_google_credentials(), + ), + patch( + "agent.anthropic_vertex_adapter._get_anthropic_sdk", + return_value=None, + ), + ): + client, model = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", + ) + assert client is None + assert model is None + + def test_uppercase_anthropic_prefix_still_dispatches_to_anthropic(self): + """``is_anthropic_vertex_model`` is case-insensitive per its + docstring — protect that contract at the auxiliary path.""" + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, _ = resolve_provider_client( + "vertex", "ANTHROPIC/claude-opus-4-8", + ) + assert isinstance(client, AnthropicAuxiliaryClient) + + def test_bare_claude_slug_dispatches_to_anthropic_on_aux_path(self): + """``agent_init.py::normalize_model_for_provider`` strips the + ``anthropic/`` prefix from the runtime main model for + provider=vertex. ``set_runtime_main`` then stores the BARE form + (``claude-opus-4-8``), and every auxiliary read via + ``_read_main_model()`` sees that bare form. + + The strict classifier ``is_anthropic_vertex_model`` intentionally + rejects bare ``claude-*`` so main-agent config typos surface as a + loud Vertex 404. The auxiliary vertex handler must widen + detection to also match bare ``claude-*`` — otherwise the + auxiliary path silently misroutes Claude calls to Vertex's + OpenAI-compat Gemini endpoint and 400s with "Malformed publisher + model" while the SAME session works fine on the main-agent path. + Direct probes that pass ``anthropic/claude-...`` with the prefix + intact would work; the gateway/runtime path (which sees the + already-stripped bare form) would 400.""" + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, model = resolve_provider_client( + "vertex", "claude-opus-4-8", is_vision=True, + ) + assert isinstance(client, AnthropicAuxiliaryClient), ( + "Bare 'claude-opus-4-8' must dispatch to AnthropicVertex on " + "the auxiliary path — the runtime main model is stored bare " + "after agent_init normalization, and any Claude-on-Vertex " + "aux call reads that bare form." + ) + assert model == "claude-opus-4-8" + + def test_bare_claude_case_insensitive(self): + """Uppercase / mixed-case bare Claude slug also dispatches.""" + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, _ = resolve_provider_client( + "vertex", "Claude-Opus-4-8", + ) + assert isinstance(client, AnthropicAuxiliaryClient) + + def test_async_mode_wraps_in_async_client(self): + """``async_mode=True`` must return the async wrapper so async + callers (compression, session_search) don't need to switch + client types based on provider.""" + from agent.auxiliary_client import ( + AsyncAnthropicAuxiliaryClient, + resolve_provider_client, + ) + + p1, p2, p3 = self._patched_anthropic_success() + with p1, p2, p3: + client, _ = resolve_provider_client( + "vertex", "anthropic/claude-opus-4-8", async_mode=True, + ) + assert isinstance(client, AsyncAnthropicAuxiliaryClient) + + +# --------------------------------------------------------------------------- +# Part B — Gemini-on-Vertex dispatch (regression on the existing path) +# --------------------------------------------------------------------------- + + +class TestVertexGeminiDispatch: + """Vertex + ``google/`` or empty model → OpenAI-compat aggregator.""" + + def test_google_prefix_builds_openai_client(self): + """The pre-fix behaviour on the ``google/`` slug — protect it + against accidental regression when the Anthropic dispatch was + added on top.""" + from agent.auxiliary_client import resolve_provider_client + from openai import OpenAI + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, model = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", + ) + assert isinstance(client, OpenAI) + assert model == "google/gemini-3.1-pro-preview" + assert client.api_key == "mocked-token" + assert "aiplatform.googleapis.com" in str(client.base_url) + + def test_no_model_falls_through_to_gemini_default(self): + """No caller-supplied model → the default aux Gemini slug picks + up. ``resolve_vision_provider_client``'s auto branch relies on + this to stand up a client on machines where ``auxiliary.vision`` + isn't configured.""" + from agent.auxiliary_client import resolve_provider_client + from openai import OpenAI + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, model = resolve_provider_client("vertex") + assert isinstance(client, OpenAI) + assert model.startswith("google/") + + def test_bare_gemini_slug_still_falls_to_gemini_aggregator(self): + """Bare ``gemini-*`` (no ``google/`` prefix) still resolves to + the OpenAI-compat aggregator — the Anthropic widening only + matches ``claude-*``. Vertex's Gemini endpoint requires the + ``google/`` prefix and will 404 the bare form, which is the + intended loud-fail behaviour for the Gemini path.""" + from agent.auxiliary_client import resolve_provider_client + from openai import OpenAI + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, model = resolve_provider_client( + "vertex", "gemini-3.1-pro-preview", + ) + assert isinstance(client, OpenAI) + assert "claude" not in (model or "").lower() + + def test_missing_gcp_credentials_returns_none(self): + from agent.auxiliary_client import resolve_provider_client + + with patch("agent.vertex_adapter.has_vertex_credentials", + return_value=False): + client, model = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", + ) + assert client is None + assert model is None + + def test_missing_oauth_token_returns_none(self): + """Credentials configured but token mint fails at call time.""" + from agent.auxiliary_client import resolve_provider_client + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=(None, None)), + ): + client, model = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", + ) + assert client is None + assert model is None + + def test_async_mode_wraps_in_async_openai(self): + from agent.auxiliary_client import resolve_provider_client + from openai import AsyncOpenAI + + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, _ = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", async_mode=True, + ) + assert isinstance(client, AsyncOpenAI) + + +# --------------------------------------------------------------------------- +# Historical regression — the bug this fix closes +# --------------------------------------------------------------------------- + + +class TestHistoricalRegression: + """Pin the exact silent-break so a future refactor of the + ``PROVIDER_REGISTRY`` auto-extension in ``hermes_cli/auth.py`` cannot + reintroduce it.""" + + def test_vertex_not_in_hardcoded_registry_still_works(self): + """The bug was: ``PROVIDER_REGISTRY.get("vertex")`` returns None, + so ``elif pconfig.auth_type == "vertex":`` was dead. This test + pins the invariant even if someone later re-declares vertex in + ``PROVIDER_REGISTRY`` (belt + braces).""" + from hermes_cli.auth import PROVIDER_REGISTRY + from agent.auxiliary_client import resolve_provider_client + + # Simulate the historical state — vertex explicitly absent from + # the registry. Even in this state, resolve_provider_client must + # succeed via the plugin-catalog fallback. + original_vertex = PROVIDER_REGISTRY.pop("vertex", None) + try: + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch("agent.vertex_adapter.get_vertex_config", + return_value=("mocked-token", "https://aiplatform.googleapis.com/x")), + ): + client, model = resolve_provider_client( + "vertex", "google/gemini-3.1-pro-preview", + ) + assert client is not None, ( + "This is exactly the historical bug — vertex silently " + "resolves to (None, None) because the plugin-catalog " + "fallback was removed or the filter widened." + ) + assert model == "google/gemini-3.1-pro-preview" + finally: + if original_vertex is not None: + PROVIDER_REGISTRY["vertex"] = original_vertex diff --git a/tests/agent/test_request_anthropic_client_vertex_dispatch.py b/tests/agent/test_request_anthropic_client_vertex_dispatch.py new file mode 100644 index 000000000000..8ecc33c58c2e --- /dev/null +++ b/tests/agent/test_request_anthropic_client_vertex_dispatch.py @@ -0,0 +1,201 @@ +"""Regression guard: the request-local Anthropic client must honour the +Claude-on-Vertex provider dispatch. + +Upstream #67142 introduced ``AIAgent._create_request_anthropic_client`` — a +per-request client that carries every in-flight ``anthropic_messages`` call +(see the two call sites in ``agent/chat_completion_helpers.py``). It was +written to mirror ``_rebuild_anthropic_client``, but only reproduced the +direct-Anthropic and Bedrock branches. On a Claude-on-Vertex deployment the +missing branch made every turn fall through to a direct Anthropic client +constructed with the *display-only* Vertex publisher base_url, so the SDK +POSTed to:: + + …/publishers/anthropic/v1/messages (HTTP 404) + +instead of the real Vertex publisher route:: + + …/publishers/anthropic/models/:rawPredict + +The shared ``_anthropic_client`` was correctly an ``AnthropicVertex``, which +is why agent init and every auxiliary task looked healthy while the main +conversation loop 404'd on the first call. + +These tests assert the *invariant* — that the request-local builder and the +shared rebuild agree on which SDK a given provider gets — rather than +snapshotting either implementation. +""" + +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def agent(): + """A minimal AIAgent stand-in carrying only the attributes the two client + builders read. Avoids the full __init__ (network/config/plugins).""" + from run_agent import AIAgent + + a = object.__new__(AIAgent) + a.api_mode = "anthropic_messages" + a.provider = "vertex" + a.model = "claude-opus-4-8" + a._anthropic_api_key = "vertex-adc" + a._anthropic_base_url = ( + "https://aiplatform.googleapis.com/v1/projects/proj-1" + "/locations/global/publishers/anthropic" + ) + a._vertex_project_id = "proj-1" + a._vertex_region = "global" + a._oauth_1m_beta_disabled = False + a._anthropic_client = None + return a + + +@pytest.fixture +def spies(monkeypatch): + """Patch both adapter factories and record which one gets called.""" + calls = {"vertex": [], "direct": [], "bedrock": []} + + vertex_mod = types.ModuleType("agent.anthropic_vertex_adapter") + + def _build_vertex(project_id, region="global", timeout=None, **kw): + calls["vertex"].append({"project_id": project_id, "region": region}) + c = MagicMock(name="AnthropicVertex") + c.base_url = "https://aiplatform.googleapis.com/v1/" + return c + + vertex_mod.build_anthropic_vertex_client = _build_vertex + + import agent.anthropic_adapter as direct_mod + + def _build_direct(api_key, base_url=None, timeout=None, **kw): + calls["direct"].append({"api_key": api_key, "base_url": base_url}) + c = MagicMock(name="Anthropic") + c.base_url = base_url + return c + + def _build_bedrock(region, **kw): + calls["bedrock"].append({"region": region}) + return MagicMock(name="AnthropicBedrock") + + monkeypatch.setitem(sys.modules, "agent.anthropic_vertex_adapter", vertex_mod) + monkeypatch.setattr(direct_mod, "build_anthropic_client", _build_direct) + monkeypatch.setattr( + direct_mod, "build_anthropic_bedrock_client", _build_bedrock, raising=False + ) + return calls + + +def _make_request_client(agent): + with patch.object( + type(agent), "_try_refresh_anthropic_client_credentials", lambda self: None + ): + return agent._create_request_anthropic_client(reason="test") + + +def test_request_client_uses_vertex_sdk_for_vertex_provider(agent, spies): + """provider=vertex must build via the AnthropicVertex adapter.""" + _make_request_client(agent) + + assert spies["vertex"] == [{"project_id": "proj-1", "region": "global"}] + assert spies["direct"] == [], ( + "regression: request-local client fell through to the direct Anthropic " + "adapter on a Claude-on-Vertex agent — every call would 404 against " + f"{agent._anthropic_base_url}/v1/messages" + ) + + +def test_request_client_never_targets_the_display_only_base_url(agent, spies): + """The publisher base_url is display-only; no client may be built on it.""" + _make_request_client(agent) + + built_on_display_url = [ + c for c in spies["direct"] if c["base_url"] == agent._anthropic_base_url + ] + assert not built_on_display_url + + +def test_request_client_and_rebuild_agree_on_provider_dispatch(agent, spies): + """The invariant: both builders must pick the same SDK for a provider. + + This is what actually broke — a provider special-cased in + ``_rebuild_anthropic_client`` but not in the request-local builder. + """ + for provider in ("vertex", "anthropic", "bedrock"): + agent.provider = provider + if provider == "bedrock": + agent._bedrock_region = "us-east-1" + + for key in spies: + spies[key].clear() + _make_request_client(agent) + request_path = {k: len(v) for k, v in spies.items()} + + for key in spies: + spies[key].clear() + agent._rebuild_anthropic_client() + rebuild_path = {k: len(v) for k, v in spies.items()} + + assert request_path == rebuild_path, ( + f"provider={provider!r}: request-local builder and " + f"_rebuild_anthropic_client disagree on SDK dispatch " + f"({request_path} vs {rebuild_path})" + ) + + +def test_vertex_region_falls_back_to_global(agent, spies): + """A missing/empty region must not reach the SDK as None.""" + agent._vertex_region = None + _make_request_client(agent) + assert spies["vertex"][0]["region"] == "global" + + +# ── Cache-key discrimination ─────────────────────────────────────────────── +# +# Upstream added a single-slot cache in front of the request-local builder +# and moved the provider dispatch onto ``key[0]`` from +# ``_request_anthropic_client_key()``. That makes the key function part of +# the dispatch: a provider missing a branch there keys as ``"direct"`` and +# can never reach its own branch in the builder, no matter that the branch +# exists. These tests pin that contract. + + +def test_vertex_keys_distinctly_from_direct(agent): + """provider=vertex must not share the ``"direct"`` cache bucket. + + If it does, the ``elif key[0] == "vertex"`` branch in + ``_create_request_anthropic_client`` is unreachable and every turn 404s + against the display-only publisher base_url. + """ + assert agent._request_anthropic_client_key()[0] == "vertex" + + agent.provider = "anthropic" + assert agent._request_anthropic_client_key()[0] == "direct" + + +def test_vertex_key_covers_project_region_and_timeout(agent): + """Anything baked into the Vertex client must invalidate the cache slot. + + ``build_anthropic_vertex_client`` bakes project, region and timeout into + the client, so a change to any of them must produce a different key — + otherwise a warm slot hands back a client pointed at the wrong publisher + route (or carrying a stale ``/model`` timeout). + """ + base = agent._request_anthropic_client_key() + + agent._vertex_project_id = "proj-2" + assert agent._request_anthropic_client_key() != base + + agent._vertex_project_id = "proj-1" + agent._vertex_region = "us-east5" + assert agent._request_anthropic_client_key() != base + + agent._vertex_region = "global" + assert agent._request_anthropic_client_key() == base + with patch( + "run_agent.get_provider_request_timeout", lambda *a, **kw: 999999 + ): + assert agent._request_anthropic_client_key() != base diff --git a/tests/agent/test_switch_model_anthropic_provider_dispatch.py b/tests/agent/test_switch_model_anthropic_provider_dispatch.py new file mode 100644 index 000000000000..d3d7760eae61 --- /dev/null +++ b/tests/agent/test_switch_model_anthropic_provider_dispatch.py @@ -0,0 +1,223 @@ +"""Regression guard: ``/model`` must rebuild the right Anthropic SDK client. + +``agent.agent_runtime_helpers.switch_model`` performs the runtime swap behind +the ``/model`` command (CLI, gateway and TUI all funnel into it). Its +``anthropic_messages`` branch rebuilt the client with +``build_anthropic_client()`` unconditionally, ignoring the provider — unlike +``agent_init``, ``_rebuild_anthropic_client`` and +``_create_request_anthropic_client``, which all dispatch on it. + +Bedrock- and Vertex-hosted Claude speak the Anthropic Messages protocol but +authenticate through their cloud's own SDK, with a base_url that has no +``/v1/messages`` route. So a ``/model`` switch on either provider replaced a +working cloud client with a direct Anthropic one and every subsequent call +failed — on Vertex, HTTP 404 against +``…/publishers/anthropic/v1/messages``. + +The tests assert the cross-site invariant (all rebuild paths agree on which +SDK a provider gets) rather than snapshotting any one implementation. +""" + +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest + +VERTEX_BASE = ( + "https://aiplatform.googleapis.com/v1/projects/proj-1" + "/locations/global/publishers/anthropic" +) +BEDROCK_BASE = "https://bedrock-runtime.eu-central-1.amazonaws.com" + + +@pytest.fixture +def spies(monkeypatch): + """Record which Anthropic client factory each code path calls.""" + calls = {"vertex": [], "direct": [], "bedrock": []} + + vertex_mod = types.ModuleType("agent.anthropic_vertex_adapter") + + def _build_vertex(project_id, region="global", timeout=None, **kw): + calls["vertex"].append({"project_id": project_id, "region": region}) + c = MagicMock(name="AnthropicVertex") + c.base_url = "https://aiplatform.googleapis.com/v1/" + return c + + def _get_vertex_cfg(*a, **kw): + return ("proj-1", "global") + + vertex_mod.build_anthropic_vertex_client = _build_vertex + vertex_mod.get_anthropic_vertex_config = _get_vertex_cfg + vertex_mod.is_anthropic_vertex_model = lambda m: str(m or "").startswith("anthropic/") + monkeypatch.setitem(sys.modules, "agent.anthropic_vertex_adapter", vertex_mod) + + import agent.anthropic_adapter as direct_mod + + def _build_direct(api_key, base_url=None, timeout=None, **kw): + calls["direct"].append({"api_key": api_key, "base_url": base_url}) + c = MagicMock(name="Anthropic") + c.base_url = base_url + return c + + def _build_bedrock(region, **kw): + calls["bedrock"].append({"region": region}) + return MagicMock(name="AnthropicBedrock") + + monkeypatch.setattr(direct_mod, "build_anthropic_client", _build_direct) + monkeypatch.setattr( + direct_mod, "build_anthropic_bedrock_client", _build_bedrock, raising=False + ) + monkeypatch.setattr(direct_mod, "resolve_anthropic_token", lambda: "", raising=False) + monkeypatch.setattr(direct_mod, "_is_oauth_token", lambda k: False, raising=False) + return calls + + +def _agent(provider, base_url, model="claude-opus-4-8"): + """Bare AIAgent carrying only what the swap path reads. + + switch_model explicitly supports this shape — see its ``_MISSING`` + sentinel comment about tests constructing agents via ``__new__``. + """ + from run_agent import AIAgent + + a = object.__new__(AIAgent) + a.model = model + a.provider = provider + a.requested_provider = provider + a.api_mode = "anthropic_messages" + a.base_url = base_url + a.api_key = "placeholder" + a.client = None + a._client_kwargs = {} + a._anthropic_client = MagicMock(name="pre-existing client") + a._anthropic_api_key = "placeholder" + a._anthropic_base_url = base_url + a._is_anthropic_oauth = False + a._config_context_length = None + a._oauth_1m_beta_disabled = False + a._credential_pool = None + a._credential_pool_entry_id = None + a._vertex_project_id = "proj-1" + a._vertex_region = "global" + a._bedrock_region = "eu-central-1" + a._primary_runtime = {} + a._fallback_activated = False + a._fallback_index = 0 + return a + + +def _switch(agent, new_model, new_provider, base_url): + """Drive switch_model far enough to observe the client rebuild. + + Everything after the rebuild (context-length probing, cache policy, + compressor refresh) is unrelated to provider dispatch; stub it so a bare + agent can get through. If the tail still raises we swallow it — the + dispatch has already happened and the spies have recorded it. + """ + from agent import agent_runtime_helpers as arh + from run_agent import AIAgent + + with ( + patch.object(AIAgent, "_ensure_lmstudio_runtime_loaded", lambda self, *a, **k: None, create=True), + patch.object(AIAgent, "_lmstudio_load_was_unverified", lambda self, *a, **k: False, create=True), + patch.object(AIAgent, "_effective_lmstudio_context_length", lambda self, *a, **k: None, create=True), + patch.object(AIAgent, "_anthropic_prompt_cache_policy", lambda self, *a, **k: (False, False), create=True), + patch.object(AIAgent, "_apply_client_headers_for_base_url", lambda self, *a, **k: None, create=True), + patch.object(AIAgent, "_create_openai_client", lambda self, *a, **k: MagicMock(), create=True), + patch("agent.credential_pool.load_pool", lambda *a, **k: None, create=True), + ): + try: + arh.switch_model( + agent, new_model, new_provider, + api_key="", base_url=base_url, api_mode="anthropic_messages", + ) + except Exception: + # Post-rebuild tail is out of scope for these assertions. + pass + + +def test_switch_to_vertex_claude_uses_vertex_sdk(spies): + """/model to another Claude on Vertex must rebuild via AnthropicVertex.""" + agent = _agent("vertex", VERTEX_BASE) + _switch(agent, "claude-sonnet-4-5", "vertex", VERTEX_BASE) + + assert spies["vertex"], ( + "regression: /model switch on Claude-on-Vertex rebuilt via the direct " + f"Anthropic adapter ({spies['direct']}) — every later call would 404 " + f"against {VERTEX_BASE}/v1/messages" + ) + assert spies["direct"] == [] + assert spies["vertex"][0] == {"project_id": "proj-1", "region": "global"} + + +def test_switch_to_bedrock_claude_uses_bedrock_sdk(spies): + """Same bug class: Bedrock must rebuild via AnthropicBedrock.""" + agent = _agent("bedrock", BEDROCK_BASE) + _switch(agent, "claude-sonnet-4-5", "bedrock", BEDROCK_BASE) + + assert spies["bedrock"], f"expected Bedrock SDK, got direct={spies['direct']}" + assert spies["direct"] == [] + # Region comes from the endpoint being switched TO, not the stale attr. + assert spies["bedrock"][0]["region"] == "eu-central-1" + + +def test_switch_to_native_anthropic_still_uses_direct_sdk(spies): + """Guard the other direction — native Anthropic must NOT regress.""" + agent = _agent("anthropic", "https://api.anthropic.com") + _switch(agent, "claude-opus-4-8", "anthropic", "https://api.anthropic.com") + + assert spies["direct"], "native Anthropic must use the direct adapter" + assert spies["vertex"] == [] + assert spies["bedrock"] == [] + + +def test_no_client_is_built_against_the_vertex_publisher_base_url(spies): + """The publisher URL is display-only; no direct client may target it.""" + agent = _agent("vertex", VERTEX_BASE) + _switch(agent, "claude-sonnet-4-5", "vertex", VERTEX_BASE) + + assert not [c for c in spies["direct"] if c["base_url"] == VERTEX_BASE] + + +def test_switching_into_vertex_from_another_provider_resolves_config(spies): + """Switching INTO vertex must not depend on attrs stashed at init. + + A session that started on native Anthropic never sets + ``_vertex_project_id`` / ``_vertex_region``, so the branch has to fall + back to the shared vertex config chain. + """ + agent = _agent("anthropic", "https://api.anthropic.com") + del agent._vertex_project_id + del agent._vertex_region + + _switch(agent, "claude-sonnet-4-5", "vertex", VERTEX_BASE) + + assert spies["vertex"] == [{"project_id": "proj-1", "region": "global"}] + assert spies["direct"] == [] + + +def test_switch_model_agrees_with_rebuild_anthropic_client(spies): + """The invariant that broke: rebuild paths must agree per provider.""" + for provider, base in ( + ("vertex", VERTEX_BASE), + ("bedrock", BEDROCK_BASE), + ("anthropic", "https://api.anthropic.com"), + ): + for key in spies: + spies[key].clear() + agent = _agent(provider, base) + _switch(agent, "claude-sonnet-4-5", provider, base) + via_switch = {k: len(v) > 0 for k, v in spies.items()} + + for key in spies: + spies[key].clear() + agent2 = _agent(provider, base) + agent2._rebuild_anthropic_client() + via_rebuild = {k: len(v) > 0 for k, v in spies.items()} + + assert via_switch == via_rebuild, ( + f"provider={provider!r}: switch_model and " + f"_rebuild_anthropic_client disagree on SDK dispatch " + f"({via_switch} vs {via_rebuild})" + ) diff --git a/tests/hermes_cli/test_anthropic_vertex_provider.py b/tests/hermes_cli/test_anthropic_vertex_provider.py new file mode 100644 index 000000000000..844a19f51dd4 --- /dev/null +++ b/tests/hermes_cli/test_anthropic_vertex_provider.py @@ -0,0 +1,372 @@ +"""Tests for the Anthropic-on-Vertex runtime-provider integration. + +Design contract: Anthropic Claude on Google Vertex AI does NOT have its +own provider name — it shares the ``vertex`` provider with Gemini-on-Vertex +because they run on the same GCP platform under the same ADC auth. The +wire transport is chosen at ``resolve_runtime_provider`` time based on the +requested model: + +* ``anthropic/claude-*`` (or bare ``claude-*``) → ``anthropic_messages`` + runtime, backed by the ``AnthropicVertex`` SDK client. +* ``google/gemini-*`` (or anything else) → ``chat_completions`` runtime, + backed by Vertex's OpenAI-compat aggregator. + +The tests below cover: + +1. The classifier (``is_anthropic_vertex_model``) recognises both prefixed + and bare Claude forms and rejects Gemini + empty strings. +2. ``resolve_runtime_provider(requested="vertex", target_model=)`` + returns the expected ``anthropic_messages`` runtime dict shape that + ``agent_init``, ``agent_runtime_helpers``, and ``run_agent`` consume. +3. ``resolve_runtime_provider(requested="vertex", target_model=)`` + returns the existing ``chat_completions`` runtime and does NOT touch + the Anthropic-Vertex adapter (regression guard against dispatch bleed). +4. All existing ``vertex`` aliases (``google-vertex``, ``vertex-ai``, + ``gcp-vertex``, ``vertexai``) still resolve to the ``vertex`` provider + AND still route Claude models through the Anthropic path. +5. Friendly ``AuthError`` messages when Vertex credentials or project_id + cannot be resolved. + +Distinct from ``test_anthropic_vertex_adapter.py``, which mocks at the +SDK seam to test client construction; this file exercises the +runtime-resolution branch that maps +``(requested_provider, target_model) → runtime dict``. +""" + +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Classifier +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "model_id, expected", + [ + # Vendor-prefixed Anthropic is the ONLY accepted form. + ("anthropic/claude-opus-4-8", True), + ("anthropic/claude-sonnet-4-5", True), + ("anthropic/claude-haiku-4-5", True), + # Case-insensitive + whitespace-tolerant. + ("ANTHROPIC/Claude-Opus-4-8", True), + (" anthropic/claude-opus-4-8 ", True), + # Bare Claude names DO NOT match — vendor prefix is required. + # See ``is_anthropic_vertex_model`` docstring: strict form is + # deliberate because Vertex is multi-vendor. + ("claude-opus-4-8", False), + ("claude-fable-5", False), + # Gemini and other models are False for the same reason (wrong + # vendor prefix or no prefix at all). + ("google/gemini-3.1-pro-preview", False), + ("gemini-3.1-pro-preview", False), + ("google/gemma-3-27b-it", False), + # Empty / non-string inputs are safely rejected. + ("", False), + (None, False), + (123, False), + ], +) +def test_is_anthropic_vertex_model(model_id, expected): + from agent.anthropic_vertex_adapter import is_anthropic_vertex_model + + assert is_anthropic_vertex_model(model_id) is expected + + +# --------------------------------------------------------------------------- +# Dispatch: Claude on Vertex → anthropic_messages runtime +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "model", + [ + "anthropic/claude-opus-4-8", + "anthropic/claude-sonnet-4-5", + # Version-suffixed IDs (Vertex's ``@YYYYMMDD`` form). + "anthropic/claude-opus-4-5@20250929", + ], +) +def test_vertex_provider_dispatches_claude_to_anthropic_messages(model, monkeypatch): + """``requested=vertex`` + ``anthropic/`` model → anthropic_messages runtime. + + Confirms the runtime dict has every field ``agent_init._is_vertex_anthropic`` + and ``run_agent._rebuild_anthropic_client`` read to construct the + AnthropicVertex client. + """ + import agent.anthropic_vertex_adapter as ava + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr(ava, "has_anthropic_vertex_credentials", lambda: True) + monkeypatch.setattr( + ava, "get_anthropic_vertex_config", lambda: ("test-project-42", "global") + ) + + rt = rp.resolve_runtime_provider(requested="vertex", target_model=model) + + assert rt["provider"] == "vertex" + assert rt["api_mode"] == "anthropic_messages" + assert rt["source"] == "vertex-anthropic-oauth" + # Placeholder key — AnthropicVertex mints its own OAuth token per request. + assert rt["api_key"] == "vertex-adc" + assert rt["anthropic_api_key"] == "vertex-adc" + # Fields the client-construction sites read to build AnthropicVertex(project_id, region). + assert rt["vertex_project_id"] == "test-project-42" + assert rt["vertex_region"] == "global" + assert rt["vertex_anthropic"] is True + # Display-only base_url (real request URL is built inside the SDK). + assert "aiplatform.googleapis.com" in rt["base_url"] + assert "publishers/anthropic" in rt["base_url"] + + +@pytest.mark.parametrize( + "alias", + ["vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"], +) +def test_all_vertex_aliases_route_claude_through_anthropic(alias, monkeypatch): + """Every ``vertex`` alias must still route Claude to the Anthropic path + — no alias regression when the ``anthropic-vertex`` provider name was + removed.""" + import agent.anthropic_vertex_adapter as ava + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr(ava, "has_anthropic_vertex_credentials", lambda: True) + monkeypatch.setattr( + ava, "get_anthropic_vertex_config", lambda: ("proj", "global") + ) + + rt = rp.resolve_runtime_provider( + requested=alias, target_model="anthropic/claude-opus-4-8" + ) + assert rt["api_mode"] == "anthropic_messages" + assert rt["provider"] == "vertex" + + +# --------------------------------------------------------------------------- +# Dispatch: Gemini on Vertex → chat_completions runtime (unchanged) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "cfg_default, expect_api_mode", + [ + # Claude in config.yaml + no explicit target_model → anthropic path. + ("anthropic/claude-opus-4-8", "anthropic_messages"), + # Gemini in config.yaml + no explicit target_model → chat_completions. + ("google/gemini-3.1-pro-preview", "chat_completions"), + ], +) +def test_vertex_dispatch_when_target_model_is_none( + cfg_default, expect_api_mode, monkeypatch +): + """Regression: cron scheduler + gateway per-turn agent resolution + call ``resolve_runtime_provider(requested="vertex", target_model=None)``. + In that path the model must be sourced from ``_get_model_config()``, + not from a local ``model_cfg`` reference that would trip Python's + static scoping (``UnboundLocalError`` — ``model_cfg`` is assigned + later in the same function body inside the auto-detect branch, so + referencing it in the ``vertex`` branch without a distinct local + name blows up on target_model=None call paths). + + Failure mode this test guards: prod's ``cron.scheduler.run_job`` + surfaced ``UnboundLocalError: cannot access local variable + 'model_cfg' where it is not associated with a value`` after the + initial refactor landed, because CLI probes always pass a truthy + ``target_model`` and the bug only fires when the short-circuit + ``target_model or ...`` falls through to evaluate ``model_cfg``. + """ + import agent.anthropic_vertex_adapter as ava + import agent.vertex_adapter as va + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr( + rp, "_get_model_config", lambda: {"provider": "vertex", "default": cfg_default} + ) + monkeypatch.setattr(ava, "has_anthropic_vertex_credentials", lambda: True) + monkeypatch.setattr( + ava, "get_anthropic_vertex_config", lambda: ("proj", "global") + ) + monkeypatch.setattr( + va, "get_vertex_config", + lambda: ("stub-token", "https://aiplatform.googleapis.com/v1beta1/projects/proj/locations/global/endpoints/openapi"), + ) + + rt = rp.resolve_runtime_provider(requested="vertex", target_model=None) + + assert rt["provider"] == "vertex" + assert rt["api_mode"] == expect_api_mode + + +def test_vertex_provider_rejects_bare_claude_to_openai_compat(monkeypatch): + """Regression: bare ``claude-*`` under ``provider=vertex`` must go + through the OpenAI-compat aggregator, NOT the Anthropic path. + + Vertex is a multi-vendor surface, so a bare Claude name is ambiguous + intent. The design deliberately routes it through the aggregator so + Vertex 404s with an actionable error ("publisher google — model + claude-opus-4-8 not found") rather than silently guessing at the + intended wire protocol. This test locks that behavior in. + """ + import agent.anthropic_vertex_adapter as ava + import agent.vertex_adapter as va + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr( + ava, "has_anthropic_vertex_credentials", + lambda: pytest.fail("anthropic-vertex creds must not be checked for bare claude names"), + ) + monkeypatch.setattr( + ava, "get_anthropic_vertex_config", + lambda: pytest.fail("anthropic-vertex config must not be read for bare claude names"), + ) + monkeypatch.setattr( + va, "get_vertex_config", + lambda: ( + "stub-oauth-token", + "https://aiplatform.googleapis.com/v1beta1/projects/proj/locations/global/endpoints/openapi", + ), + ) + + rt = rp.resolve_runtime_provider( + requested="vertex", target_model="claude-opus-4-8" + ) + assert rt["api_mode"] == "chat_completions" + assert rt["provider"] == "vertex" + assert rt.get("vertex_anthropic") is not True + + +def test_vertex_provider_still_dispatches_gemini_to_chat_completions(monkeypatch): + """Regression: Gemini-on-Vertex must NOT be touched by the Anthropic + dispatch branch. Runtime resolution must call ``get_vertex_config`` + (the OpenAI-compat path) and never invoke the anthropic-vertex adapter. + """ + import agent.anthropic_vertex_adapter as ava + import agent.vertex_adapter as va + from hermes_cli import runtime_provider as rp + + calls = {"anthropic_vertex_creds": 0, "anthropic_vertex_cfg": 0} + monkeypatch.setattr( + ava, + "has_anthropic_vertex_credentials", + lambda: (calls.__setitem__("anthropic_vertex_creds", calls["anthropic_vertex_creds"] + 1), True)[1], + ) + monkeypatch.setattr( + ava, + "get_anthropic_vertex_config", + lambda: (calls.__setitem__("anthropic_vertex_cfg", calls["anthropic_vertex_cfg"] + 1), ("proj", "global"))[1], + ) + monkeypatch.setattr( + va, "get_vertex_config", + lambda: ( + "stub-oauth-token", + "https://aiplatform.googleapis.com/v1beta1/projects/proj/locations/global/endpoints/openapi", + ), + ) + + rt = rp.resolve_runtime_provider( + requested="vertex", target_model="google/gemini-3.1-pro-preview" + ) + assert rt["api_mode"] == "chat_completions" + assert rt["provider"] == "vertex" + assert rt.get("vertex_anthropic") is not True + assert rt["source"] == "vertex-oauth" + # The Anthropic-Vertex adapter must not have been consulted at all. + assert calls == {"anthropic_vertex_creds": 0, "anthropic_vertex_cfg": 0} + + +# --------------------------------------------------------------------------- +# Removed provider name: back-compat guard +# --------------------------------------------------------------------------- + +def test_no_standalone_anthropic_vertex_provider(): + """The old ``anthropic-vertex`` ProviderProfile was intentionally + removed as part of the refactor to a single ``vertex`` provider — + dispatch is now model-driven. Confirm the name doesn't resolve so we + don't accidentally re-introduce a duplicate registration.""" + from providers import get_provider_profile + + # The ``vertex`` provider still exists. + assert get_provider_profile("vertex").name == "vertex" + # The old ``anthropic-vertex`` name no longer resolves. + assert get_provider_profile("anthropic-vertex") is None + + +@pytest.mark.parametrize( + "old_alias", ["claude-vertex", "anthropic-gcp", "vertex-anthropic"], +) +def test_removed_aliases_no_longer_resolve(old_alias): + """Old aliases from the standalone-provider era must not resolve — + surface a clear error rather than silently routing to the wrong thing.""" + from providers import get_provider_profile + + assert get_provider_profile(old_alias) is None + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + +def test_missing_credentials_raises_actionable_autherror(monkeypatch): + import agent.anthropic_vertex_adapter as ava + from hermes_cli import runtime_provider as rp + from hermes_cli.auth import AuthError + + monkeypatch.setattr(ava, "has_anthropic_vertex_credentials", lambda: False) + + with pytest.raises(AuthError) as exc: + rp.resolve_runtime_provider( + requested="vertex", target_model="anthropic/claude-opus-4-8" + ) + msg = str(exc.value) + assert "OAuth2" in msg + # Actionable next step: Vertex Model Garden enablement. + assert "Model Garden" in msg + + +def test_missing_project_id_raises_autherror(monkeypatch): + """Credentials resolved, but no project_id inferable — surface a + project-specific error message so the user knows exactly what to set.""" + import agent.anthropic_vertex_adapter as ava + from hermes_cli import runtime_provider as rp + from hermes_cli.auth import AuthError + + monkeypatch.setattr(ava, "has_anthropic_vertex_credentials", lambda: True) + monkeypatch.setattr(ava, "get_anthropic_vertex_config", lambda: (None, None)) + + with pytest.raises(AuthError) as exc: + rp.resolve_runtime_provider( + requested="vertex", target_model="anthropic/claude-opus-4-8" + ) + msg = str(exc.value) + assert "project_id" in msg + + +# --------------------------------------------------------------------------- +# Model normalization +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "input_model, expected", + [ + # Claude on Vertex: strip ``anthropic/`` so the AnthropicVertex SDK + # gets the bare model name it needs for URL construction. + ("anthropic/claude-opus-4-8", "claude-opus-4-8"), + ("anthropic/claude-sonnet-4-5", "claude-sonnet-4-5"), + ("ANTHROPIC/claude-opus-4-8", "claude-opus-4-8"), + # Bare Claude passes through unchanged. + ("claude-opus-4-8", "claude-opus-4-8"), + # Gemini keeps its ``google/`` prefix (required by the OpenAI-compat + # aggregator wire). + ("google/gemini-3.1-pro-preview", "google/gemini-3.1-pro-preview"), + # Bare Gemini too — Vertex will 404 with a clear hint, that's the + # user-facing failure mode. + ("gemini-3.1-pro-preview", "gemini-3.1-pro-preview"), + ], +) +def test_normalize_model_for_vertex_strips_anthropic_prefix_only( + input_model, expected +): + """Normalization contract for ``vertex``: strip ``anthropic/`` (needed + for AnthropicVertex SDK URL construction), preserve everything else.""" + from hermes_cli.model_normalize import normalize_model_for_provider + + assert normalize_model_for_provider(input_model, "vertex") == expected diff --git a/tests/run_agent/test_switch_model_vertex_anthropic.py b/tests/run_agent/test_switch_model_vertex_anthropic.py new file mode 100644 index 000000000000..a00819c20f16 --- /dev/null +++ b/tests/run_agent/test_switch_model_vertex_anthropic.py @@ -0,0 +1,279 @@ +"""Regression tests for the ``switch_model`` Anthropic-on-Vertex construction site. + +``agent/agent_runtime_helpers.py::switch_model`` handles the mid-session +``/model`` swap. Its ``anthropic_messages`` branch used to unconditionally +call ``build_anthropic_client(...)`` — which is the wrong client for +Anthropic Claude on Vertex, because that path talks to the native +Anthropic endpoint with a static API key. A Gemini-on-Vertex session +that switched to ``anthropic/claude-opus-4-8`` mid-conversation would +build the native client with no API key on the deployment, hitting a +401 (or worse, silently succeeding against a stale-cached one). + +The fix (reviewed on the upstream PR that added the Anthropic-on-Vertex +provider) adds a ``new_provider == "vertex"`` branch that builds an +``AnthropicVertex`` client instead, resolving project/region freshly +via ``get_anthropic_vertex_config()`` so a session that started on a +non-Vertex provider can still swap to Vertex Claude cleanly. + +These tests exercise the switch on a live agent facade and pin the +invariants: + +* ``build_anthropic_vertex_client`` is the client factory called on + the switch — NOT ``build_anthropic_client``. +* Project/region are populated from ``get_anthropic_vertex_config()`` + and stashed on the agent (``_vertex_project_id`` / + ``_vertex_region``) for subsequent rebuild sites. +* Non-Vertex ``anthropic_messages`` providers (native Anthropic, + MiniMax, ...) still take the ``build_anthropic_client`` path. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from agent.agent_runtime_helpers import switch_model + + +def _make_agent(current_provider: str, current_model: str, current_api_mode: str = "chat_completions"): + """Bare agent facade with just the attributes ``switch_model`` reads/writes. + + Mirrors the fixture shape used in + ``tests/run_agent/test_switch_model_pool_reload_52727.py``; kept + local so a future refactor of the pool-reload fixture doesn't + accidentally re-shape this test's expectations. + """ + agent = MagicMock(name=f"Agent[{current_provider}]") + agent.provider = current_provider + agent.model = current_model + agent.base_url = f"https://{current_provider}.example/v1" + agent.api_key = f"{current_provider}-key" + agent.api_mode = current_api_mode + agent.client = MagicMock(name="Client") + agent._client_kwargs = {} + agent._anthropic_client = None + agent._anthropic_api_key = "" + agent._anthropic_base_url = None + agent._is_anthropic_oauth = False + agent._vertex_project_id = None + agent._vertex_region = None + agent._config_context_length = None + agent._transport_cache = {} + agent._cached_system_prompt = "cached-system-prompt" + agent.context_compressor = None + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent._primary_runtime = {} + agent._fallback_activated = False + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._credential_pool = None + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(False, False)) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + return agent + + +class TestSwitchModelVertexAnthropic: + """The Vertex Claude construction path — the review-caught gap.""" + + def test_gemini_on_vertex_to_claude_on_vertex_builds_anthropic_vertex(self): + """The interesting real-world flow: session is happily talking to + Gemini-on-Vertex, user runs ``/model anthropic/claude-opus-4-8``, + rebuild constructs an AnthropicVertex client with the resolved + project/region. Pre-fix: this called build_anthropic_client and + broke the session. + """ + agent = _make_agent( + current_provider="vertex", + current_model="google/gemini-3.1-pro-preview", + current_api_mode="chat_completions", + ) + vertex_client_sentinel = MagicMock(name="AnthropicVertexClient") + native_client_sentinel = MagicMock(name="NativeAnthropicClient") + + with ( + patch( + "agent.anthropic_vertex_adapter.get_anthropic_vertex_config", + return_value=("khala-498208", "global"), + ), + patch( + "agent.anthropic_vertex_adapter.build_anthropic_vertex_client", + return_value=vertex_client_sentinel, + ) as build_vertex_mock, + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=native_client_sentinel, + ) as build_native_mock, + patch("agent.credential_pool.load_pool", return_value=None), + ): + switch_model( + agent, + new_model="anthropic/claude-opus-4-8", + new_provider="vertex", + api_key="", + base_url="", + api_mode="anthropic_messages", + ) + + # Vertex client factory fires; native factory does not. + assert build_vertex_mock.called, ( + "AnthropicVertex must be constructed on a vertex + anthropic_messages " + "switch — build_anthropic_vertex_client was not called." + ) + assert not build_native_mock.called, ( + "build_anthropic_client MUST NOT fire on the vertex path — " + "it would send Anthropic-native traffic to the wrong endpoint " + "with the wrong auth." + ) + call_kwargs = build_vertex_mock.call_args + pos = call_kwargs.args + # build_anthropic_vertex_client is called positionally: (project_id, region) + # plus a timeout kwarg. + assert pos[0] == "khala-498208" + assert pos[1] == "global" + assert "timeout" in call_kwargs.kwargs + + # State reflects the new client + resolved config. + assert agent._anthropic_client is vertex_client_sentinel + assert agent._vertex_project_id == "khala-498208" + assert agent._vertex_region == "global" + # AnthropicVertex handles bearer minting internally; the api_key + # slot carries a non-empty placeholder so downstream "auth + # resolved" checks pass without paperwork-shuffling. + assert agent.api_key == "vertex-adc" + assert agent._anthropic_api_key == "vertex-adc" + # Not a native-Anthropic OAuth session. + assert agent._is_anthropic_oauth is False + # OpenAI-shaped client is cleared — this session is now on the + # Anthropic Messages path. + assert agent.client is None + # And the agent's provider/model/api_mode reflect the new state. + assert agent.provider == "vertex" + assert agent.model == "anthropic/claude-opus-4-8" + assert agent.api_mode == "anthropic_messages" + + def test_none_region_from_config_falls_back_to_global(self): + """If get_anthropic_vertex_config returns region=None (rare — + credentials with no region and no VERTEX_REGION env), the switch + must not leave the client with region=None. It falls back to + 'global' matching the runtime-provider default.""" + agent = _make_agent("openrouter", "anthropic/claude-opus-4-8") + with ( + patch( + "agent.anthropic_vertex_adapter.get_anthropic_vertex_config", + return_value=("khala-498208", None), + ), + patch( + "agent.anthropic_vertex_adapter.build_anthropic_vertex_client", + return_value=MagicMock(), + ) as build_vertex_mock, + patch("agent.credential_pool.load_pool", return_value=None), + ): + switch_model( + agent, + new_model="anthropic/claude-opus-4-8", + new_provider="vertex", + api_mode="anthropic_messages", + ) + + assert agent._vertex_region == "global" + # And the factory saw the fallback too. + assert build_vertex_mock.call_args.args[1] == "global" + + def test_native_anthropic_switch_still_uses_build_anthropic_client(self): + """Regression guard: the fix must not accidentally affect switches + onto the native Anthropic provider or other non-Vertex + anthropic_messages providers (MiniMax, Alibaba, ...). Those still + go through build_anthropic_client.""" + agent = _make_agent("openrouter", "anthropic/claude-opus-4-8") + with ( + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=MagicMock(name="NativeAnthropicClient"), + ) as build_native_mock, + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="sk-ant-token-here", + ), + patch( + "agent.anthropic_vertex_adapter.build_anthropic_vertex_client", + return_value=MagicMock(name="AnthropicVertexClient"), + ) as build_vertex_mock, + patch("agent.credential_pool.load_pool", return_value=None), + ): + switch_model( + agent, + new_model="claude-opus-4-8", + new_provider="anthropic", + api_key="", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + + assert build_native_mock.called + assert not build_vertex_mock.called + + +class TestSwitchModelEmptyBaseUrlGuard: + """The #47828 stale-base_url guard must not block Claude on Vertex/Bedrock. + + That guard raises when a provider change resolves an empty ``base_url``, on + the premise that "empty means upstream resolution failed". The premise does + not hold for the Anthropic cloud partner SDKs: ``AnthropicVertex`` and + ``AnthropicBedrock`` derive their endpoint from project/region internally, + so ``switch_model()`` legitimately has no URL to pass. Without the + exemption, `/model anthropic/claude-*` on ``provider: vertex`` raises + instead of switching. + """ + + def test_vertex_anthropic_switch_survives_empty_base_url(self): + agent = _make_agent("openrouter", "openai/gpt-5") + with ( + patch( + "agent.anthropic_vertex_adapter.get_anthropic_vertex_config", + return_value=("khala-498208", "global"), + ), + patch( + "agent.anthropic_vertex_adapter.build_anthropic_vertex_client", + return_value=MagicMock(), + ), + patch("agent.credential_pool.load_pool", return_value=None), + ): + # No base_url= argument at all: this is what switch_model() resolves + # for a cloud partner model. + switch_model( + agent, + new_model="anthropic/claude-opus-4-8", + new_provider="vertex", + api_mode="anthropic_messages", + ) + assert agent.provider == "vertex" + assert agent.model == "anthropic/claude-opus-4-8" + + def test_guard_still_fires_for_a_non_cloud_provider(self): + """The exemption must stay scoped — an ordinary provider change with no + resolved base_url is still the bug #47828 was written to catch.""" + agent = _make_agent("openrouter", "openai/gpt-5") + with patch("agent.credential_pool.load_pool", return_value=None): + with pytest.raises(ValueError, match="no base_url resolved"): + switch_model( + agent, + new_model="minimax/minimax-m2", + new_provider="minimax", + api_mode="chat_completions", + ) + + def test_guard_still_fires_for_vertex_outside_anthropic_messages(self): + """Gemini-on-Vertex goes through the OpenAI-compat aggregator and DOES + need a real base_url, so the exemption must not cover it.""" + agent = _make_agent("openrouter", "openai/gpt-5") + with patch("agent.credential_pool.load_pool", return_value=None): + with pytest.raises(ValueError, match="no base_url resolved"): + switch_model( + agent, + new_model="google/gemini-3.5-flash", + new_provider="vertex", + api_mode="chat_completions", + ) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 337bc24d775f..683a5c694976 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -1084,8 +1084,7 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: # frontier models. Falling back to text would be a regression for # them. _AGGREGATORS = { - "openrouter", "nous", "vertex", "bedrock", "anthropic-vertex", - "google-vertex", + "openrouter", "nous", "vertex", "bedrock", "google-vertex", } if p in _AGGREGATORS: return True diff --git a/website/docs/guides/anthropic-vertex.md b/website/docs/guides/anthropic-vertex.md new file mode 100644 index 000000000000..a88a466b61be --- /dev/null +++ b/website/docs/guides/anthropic-vertex.md @@ -0,0 +1,168 @@ +--- +sidebar_position: 16 +title: "Anthropic on Google Vertex AI" +description: "Use Hermes Agent with Anthropic Claude models on Vertex AI — OAuth2 via ADC, GCP billing, no Anthropic API key required" +--- + +# Anthropic on Google Vertex AI + +Hermes Agent supports **Anthropic Claude models on Google Cloud Vertex AI**. This is the third way to run Claude from Hermes, alongside the native [Anthropic provider](/guides/anthropic) (API key from `console.anthropic.com`, billed by Anthropic) and the [AWS Bedrock provider](/guides/aws-bedrock) (Claude via AWS, billed by AWS). Same Anthropic Messages API surface as native Anthropic — same feature set (prompt caching, adaptive thinking, tool-use streaming, xhigh effort) — but authenticated with a Google Cloud OAuth2 token and billed through your GCP account. + +:::info Vertex is one Hermes provider hosting two model families +Anthropic Claude on Vertex uses the **same `vertex` provider** as Gemini on Vertex — they run on the same GCP platform under the same ADC auth and share every routing config. Hermes chooses the correct wire (AnthropicVertex vs. Vertex's OpenAI-compat aggregator) automatically based on the model name's vendor prefix: only `anthropic/…` routes through Anthropic's SDK; everything else (Gemini, Gemma, and any future partner family with its own prefix) routes to the aggregator. The `anthropic/` prefix is required — bare `claude-…` without a vendor prefix falls through to the aggregator and gets a clean Vertex 404, on purpose (see "Dispatch table" below). You never paste a Claude API key when using this provider. +::: + +## Prerequisites + +- **A Google Cloud project** with the **Vertex AI API enabled** and billing active. +- **Anthropic models enabled in Vertex Model Garden.** Claude models on Vertex are partner models — they require a one-time console click per model to accept Anthropic's terms and start the Google Cloud Marketplace subscription. Go to [Vertex AI Model Garden](https://console.cloud.google.com/vertex-ai/model-garden), filter by **Anthropic**, and click **Enable** on each Claude SKU you want reachable (Opus, Sonnet, Haiku). Without this, every Claude request returns `HTTP 404: Publisher model … was not found` — the same 404 shape you'd see for a genuinely unknown model. +- **Credentials**, one of: + - a **service-account JSON** key file with the `roles/aiplatform.user` role, or + - **Application Default Credentials** via `gcloud auth application-default login` (or the metadata server when running on a GCP VM). +- **`anthropic>=0.39.0`** and **`google-auth`** — installed automatically the first time you select the provider (lazy install), or explicitly with `pip install 'anthropic>=0.39.0' 'hermes-agent[anthropic]'`. + +## Quick Start + +```bash +# Option A — service account JSON (recommended for servers / gateways) +echo "VERTEX_CREDENTIALS_PATH=/path/to/service-account.json" >> ~/.hermes/.env + +# Option B — Application Default Credentials (good for local dev) +gcloud auth application-default login + +# Set the routing config +cat >> ~/.hermes/config.yaml <<'YAML' +model: + default: anthropic/claude-opus-4-8 + provider: vertex + +vertex: + project_id: my-gcp-project + region: global +YAML + +# Start chatting — no API key prompts +hermes chat +``` + +## Configuration + +Anthropic on Vertex is a routing mode of the shared `vertex` provider — it shares its credential and routing configuration with [Gemini-on-Vertex](/guides/google-vertex). If you already have Gemini on Vertex working, the same `vertex.project_id` / `vertex.region` values apply here; only the `model.default` value changes. + +- The **credential path** is a pointer to a secret and lives in `~/.hermes/.env`. +- **Project ID and region** are non-secret routing settings and live in `~/.hermes/config.yaml`. + +`~/.hermes/.env`: + +```bash +# One of these (checked in this order); omit both to use ADC: +VERTEX_CREDENTIALS_PATH=/path/to/service-account.json +GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json +``` + +`~/.hermes/config.yaml`: + +```yaml +model: + default: anthropic/claude-opus-4-8 # ``anthropic/`` prefix picks the AnthropicVertex path + provider: vertex + +vertex: + project_id: my-gcp-project # blank → use the project embedded in the credentials + region: global # or a regional endpoint that serves the model +``` + +The `anthropic/` prefix is what Hermes uses to dispatch the request onto the AnthropicVertex SDK. The prefix is **required** — Vertex Model Garden is a multi-vendor surface, so a bare `claude-opus-4-8` has no unambiguous meaning and Hermes deliberately does not guess. Names without the `anthropic/` prefix fall through to the OpenAI-compat aggregator path, where Vertex will 404 with a message pointing at the missing prefix. Gemini models continue to use the `google/gemini-…` naming (required by Vertex's OpenAI-compat aggregator) on the same provider. + +:::tip Environment variables win over config.yaml +`VERTEX_PROJECT_ID` and `VERTEX_REGION` override the `vertex.project_id` / `vertex.region` values in `config.yaml`. Use them for per-shell overrides; keep the durable settings in `config.yaml`. +::: + +### How dispatch works + +Hermes classifies the requested model at runtime: + +| Model shape | Wire route | SDK | +|------------------------------------|-----------------------------------------------|------------------------------| +| `anthropic/claude-…` | `publishers/anthropic/models/:rawPredict` | `anthropic.AnthropicVertex` | +| `google/gemini-…`, everything else | `endpoints/openapi/…` (OpenAI-compat) | OpenAI SDK against Vertex | +| bare `claude-…` (missing prefix) | falls through to OpenAI-compat, Vertex 404s | user-facing error | + +The `anthropic/` vendor prefix is stripped internally before hitting the wire — the AnthropicVertex SDK constructs its URL as `.../publishers/anthropic/models/{model}:rawPredict` and expects a bare model name in the request body. + +### How authentication works + +1. Hermes resolves Google credentials in this order: `VERTEX_CREDENTIALS_PATH` → `GOOGLE_APPLICATION_CREDENTIALS` → Application Default Credentials. +2. Hermes constructs an `anthropic.AnthropicVertex(project_id, region, credentials=…)` client and hands it the Credentials object. +3. The Anthropic SDK's own transport mints and refreshes access tokens on demand via `google-auth`, and constructs the correct `publishers/anthropic/models/:rawPredict` URL for every request. +4. Because the Anthropic SDK handles auth internally, mid-session token expiry is transparent — no separate refresh logic on the Hermes side. + +## Available Models + +Vertex uses Anthropic's own model IDs, with or without a `@YYYYMMDD` version suffix. Prefix with `anthropic/` in your Hermes config so the dispatcher routes through the AnthropicVertex SDK. + +| Model | `model.default` value in `config.yaml` | +|-------|----------------------------------------------| +| Claude Opus 4.8 | `anthropic/claude-opus-4-8` | +| Claude Opus 4.5 | `anthropic/claude-opus-4-5` | +| Claude Opus 4.1 | `anthropic/claude-opus-4-1` | +| Claude Opus 4 | `anthropic/claude-opus-4` | +| Claude Sonnet 4.5 | `anthropic/claude-sonnet-4-5` | +| Claude Sonnet 4 | `anthropic/claude-sonnet-4` | +| Claude Haiku 4.5 | `anthropic/claude-haiku-4-5` | +| Claude 3.7 Sonnet | `anthropic/claude-3-7-sonnet` | +| Claude 3.5 Sonnet v2 | `anthropic/claude-3-5-sonnet-v2` | +| Claude 3.5 Haiku | `anthropic/claude-3-5-haiku` | + +:::warning Per-region model availability +Not every Anthropic SKU is served in every region. `global` serves the broadest set (all currently-listed frontier models); regional endpoints (`us-east5`, `europe-west1`, `asia-southeast1`, …) may 404 on newer models. Check the [Anthropic Claude on Vertex model reference](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/use-claude) for the current region matrix. When in doubt, pin `region: global` — it routes to the best-available regional replica automatically. +::: + +## Feature Parity + +Claude on Vertex uses Anthropic's `AnthropicVertex` SDK, which speaks the same wire protocol as `anthropic.Anthropic`. All Claude features work identically: + +- **Prompt caching** (input token cost reduction on multi-turn conversations) +- **Adaptive thinking** (`reasoning_effort` maps to `output_config.effort`) +- **`xhigh` effort level** on Opus 4.7+ +- **Tool-use streaming** (`fine-grained-tool-streaming` beta) +- **Interleaved thinking** across tool calls +- **Extended thinking budgets** on Opus 4.5 and older +- **1M-token context window** on Opus 4.6+ and Sonnet 4.6+ (see below) + +### 1M context — no header required + +The 1M-token context window is generally available on Vertex-hosted Opus 4.6+ / Sonnet 4.6+ as of March 2026 ([Anthropic GA announcement](https://claude.com/blog/1m-context-ga)). Requests over 200K tokens work automatically on this wire — no beta header, no code change, no per-project opt-in beyond the standard Vertex Model Garden enablement for the SKU. If you still send `context-1m-2025-08-07` (e.g. from code that talks to native Anthropic in a mixed-backend setup) the Vertex path silently ignores it. + +Hermes deliberately does not attach the header on the Vertex path — sending a no-op is misleading and would break the header-based gating story on native Anthropic backends that DO still require it. There is nothing to configure to unlock 1M through this provider. + +## Switching Models Mid-Session + +```text +/model anthropic/claude-opus-4-8 +/model anthropic/claude-sonnet-4-5 +/model anthropic/claude-haiku-4-5 +``` + +`/model` switches among already-configured providers and models; it does not collect new credentials. Configure the provider once via `hermes model` (or by editing `config.yaml` directly), then use `/model` to hot-swap. Switching between Anthropic and Gemini models on the same `vertex` provider is a single `/model` call — no provider swap, no re-auth. + +## Diagnostics + +```bash +hermes doctor +``` + +The doctor reports whether Vertex credentials can be resolved (service-account path or ADC) and whether the provider is configured. + +### Common failure modes + +- **`HTTP 404: Publisher model … was not found`** — the specific Claude model is not enabled in your project's Vertex Model Garden. Enable it via the console (see Prerequisites). +- **`HTTP 400: Publisher model … not available in region`** — the model is enabled but not served in the region you pinned. Switch `region` to `global` or a region listed in the [Anthropic on Vertex model reference](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/use-claude). +- **`google.auth.exceptions.DefaultCredentialsError`** — no service-account JSON and no ADC. Run `gcloud auth application-default login` or set `VERTEX_CREDENTIALS_PATH` in `~/.hermes/.env`. +- **`HTTP 401`** after a long-idle gateway session — ADC refresh token expired. Configure a service-account JSON via `VERTEX_CREDENTIALS_PATH` for long-running gateways; ADC is a developer-workflow credential. + +## Related + +- [Google Vertex AI (Gemini)](/guides/google-vertex) — same platform, Gemini models via the OpenAI-compatible endpoint. Same `vertex` provider, same `vertex.project_id` / `vertex.region` config; the model name (`google/gemini-…` vs. `anthropic/claude-…`) picks the wire. +- [Anthropic (native)](/guides/anthropic) — Claude via Anthropic's own API. Different auth (`x-api-key`), different billing. +- [AWS Bedrock](/guides/aws-bedrock) — Claude via AWS. `AnthropicBedrock` SDK, IAM credentials. diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 0d4569accd77..32488367cdfc 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -49,7 +49,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) | | **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) | | **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) | -| **Google Vertex AI** | `hermes model` → "Google Vertex AI" (provider: `vertex`; OAuth2 via service-account JSON or ADC, GCP billing) | +| **Google Vertex AI (Gemini)** | `hermes model` → "Google Vertex AI" (provider: `vertex`, model: `google/gemini-…`; OAuth2 via service-account JSON or ADC, GCP billing) | +| **Anthropic on Vertex AI** | `provider: vertex`, model: `anthropic/claude-…` in config.yaml (Claude via Vertex Model Garden; same `vertex` provider as Gemini — model name selects the wire; OAuth2 via service-account JSON or ADC, GCP billing). See [Anthropic on Vertex](/guides/anthropic-vertex). | | **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) | | **Azure AI Foundry** | `hermes model` → "Azure AI Foundry" (provider: `azure-foundry`; uses Azure OpenAI / Foundry endpoint and key) | | **AWS Bedrock** | `hermes model` → "AWS Bedrock" (provider: `bedrock`; standard AWS credentials chain via boto3) |