diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fa5e391a4fd2c..b7334c3672d29 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -261,6 +261,38 @@ def build_anthropic_client(api_key: str, base_url: str = None): return _anthropic_sdk.Anthropic(**kwargs) +def build_vertex_client(project_id: str, region: str = "us-east5"): + """Create an AnthropicVertex client for Google Vertex AI. + + Uses Google Cloud Application Default Credentials (ADC) for auth. + No API key needed — authenticate via ``gcloud auth application-default login`` + or a service account key. + + Returns an anthropic.AnthropicVertex instance (same .messages interface). + """ + if _anthropic_sdk is None: + raise ImportError( + "The 'anthropic' package is required for the Vertex AI provider. " + "Install it with: pip install 'anthropic[vertex]'" + ) + if not hasattr(_anthropic_sdk, "AnthropicVertex"): + raise ImportError( + "Vertex AI support requires the 'anthropic[vertex]' extra. " + "Install it with: pip install 'anthropic[vertex]'" + ) + from httpx import Timeout + + kwargs = { + "project_id": project_id, + "region": region, + "timeout": Timeout(timeout=900.0, connect=10.0), + } + if _COMMON_BETAS: + kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + + return _anthropic_sdk.AnthropicVertex(**kwargs) + + def read_claude_code_credentials() -> Optional[Dict[str, Any]]: """Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f743a64eeb6c6..da88a67ac383c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -73,6 +73,8 @@ "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", + "vertex-ai": "vertex", + "google-vertex": "vertex", } @@ -102,6 +104,7 @@ def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False "minimax": "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", "anthropic": "claude-haiku-4-5-20251001", + "vertex": "claude-haiku-4-5-20251001", "ai-gateway": "google/gemini-3-flash", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", @@ -676,6 +679,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: return None, None for provider_id, pconfig in PROVIDER_REGISTRY.items(): + if pconfig.auth_type == "gcloud_adc" and provider_id == "vertex": + result = _try_vertex() + if result[0] is not None: + return result + continue if pconfig.auth_type != "api_key": continue if provider_id == "anthropic": @@ -955,6 +963,27 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model +def _try_vertex() -> Tuple[Optional[Any], Optional[str]]: + """Try to build a Vertex AI auxiliary client using Google ADC.""" + try: + from agent.anthropic_adapter import build_vertex_client + except ImportError: + return None, None + + project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + region = os.getenv("CLOUD_ML_REGION", "us-east5").strip() + if not project_id: + return None, None + + model = _API_KEY_PROVIDER_AUX_MODELS.get("vertex", "claude-haiku-4-5-20251001") + logger.debug("Auxiliary client: Vertex AI (%s) project=%s region=%s", model, project_id, region) + try: + real_client = build_vertex_client(project_id, region) + except (ImportError, Exception): + return None, None + return AnthropicAuxiliaryClient(real_client, model, "", "", is_oauth=False), model + + def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[str]]: """Resolve a specific forced provider. Returns (None, None) if creds missing.""" if forced == "openrouter": @@ -975,6 +1004,12 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st logger.warning("auxiliary.provider=codex but no Codex OAuth token found (run: hermes model)") return client, model + if forced == "vertex": + client, model = _try_vertex() + if client is None: + logger.warning("auxiliary.provider=vertex but ANTHROPIC_VERTEX_PROJECT_ID not set") + return client, model + if forced == "main": # "main" = skip OpenRouter/Nous, use the main chat model's credentials. for try_fn in (_try_custom_endpoint, _try_codex, _resolve_api_key_provider): @@ -1326,6 +1361,15 @@ def resolve_provider_client( logger.warning("resolve_provider_client: unknown provider %r", provider) return None, None + if pconfig.auth_type == "gcloud_adc": + if provider == "vertex": + client, default_model = _try_vertex() + if client is None: + logger.warning("resolve_provider_client: vertex requested but ANTHROPIC_VERTEX_PROJECT_ID not set") + return None, None + final_model = model or default_model + return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + if pconfig.auth_type == "api_key": if provider == "anthropic": client, default_model = _try_anthropic() @@ -1442,6 +1486,8 @@ def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Option return _try_codex() if provider == "anthropic": return _try_anthropic() + if provider == "vertex": + return _try_vertex() if provider == "custom": return _try_custom_endpoint() return None, None diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 5b1d3376afa1b..1c87f882a0011 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -34,6 +34,7 @@ "github-models", "kimi", "moonshot", "claude", "deep-seek", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", "qwen-portal", + "vertex", "vertex-ai", "google-vertex", }) diff --git a/agent/models_dev.py b/agent/models_dev.py index cc360d77cf600..5c7fa78c75a66 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -147,6 +147,7 @@ def has_api_url(self) -> bool: PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", "anthropic": "anthropic", + "vertex": "anthropic", # Vertex AI serves Anthropic's models "zai": "zai", "kimi-coding": "kimi-for-coding", "minimax": "minimax", diff --git a/cli.py b/cli.py index f0edf67ee29bf..78c6df73b1f84 100644 --- a/cli.py +++ b/cli.py @@ -2290,7 +2290,9 @@ def _ensure_runtime_credentials(self) -> bool: resolved_acp_command = runtime.get("command") resolved_acp_args = list(runtime.get("args") or []) resolved_credential_pool = runtime.get("credential_pool") - if not isinstance(api_key, str) or not api_key: + # Vertex AI uses Google ADC — no API key or base URL needed. + _is_vertex = resolved_provider == "vertex" + if not _is_vertex and (not isinstance(api_key, str) or not api_key): # Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often # don't require authentication. When a base_url IS configured but # no API key was found, use a placeholder so the OpenAI SDK @@ -2308,7 +2310,7 @@ def _ensure_runtime_credentials(self) -> bool: print("\n⚠️ Provider resolver returned an empty API key. " "Set OPENROUTER_API_KEY or run: hermes setup") return False - if not isinstance(base_url, str) or not base_url: + if not _is_vertex and (not isinstance(base_url, str) or not base_url): print("\n⚠️ Provider resolver returned an empty base URL. " "Check your provider config or run: hermes setup") return False @@ -2440,6 +2442,8 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No acp_command=runtime.get("command"), acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"), + project_id=runtime.get("project_id"), + region=runtime.get("region"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, verbose_logging=self.verbose, diff --git a/gateway/run.py b/gateway/run.py index 27703a1024874..109618f88edcf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -311,6 +311,8 @@ def _resolve_runtime_agent_kwargs() -> dict: "command": runtime.get("command"), "args": list(runtime.get("args") or []), "credential_pool": runtime.get("credential_pool"), + "project_id": runtime.get("project_id"), + "region": runtime.get("region"), } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b7360fdd32805..efc1336bf1543 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -175,6 +175,13 @@ class ProviderConfig: inference_base_url="https://api.anthropic.com", api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), ), + "vertex": ProviderConfig( + id="vertex", + name="Google Vertex AI (Claude)", + auth_type="gcloud_adc", + inference_base_url="", + api_key_env_vars=("ANTHROPIC_VERTEX_PROJECT_ID",), + ), "alibaba": ProviderConfig( id="alibaba", name="Alibaba Cloud (DashScope)", @@ -831,6 +838,7 @@ def resolve_provider( "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", + "vertex-ai": "vertex", "vertex_ai": "vertex", "google-vertex": "vertex", # Local server aliases — route through the generic custom provider "lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom", "ollama": "custom", "vllm": "custom", "llamacpp": "custom", @@ -869,6 +877,12 @@ def resolve_provider( except Exception as e: logger.debug("Could not detect active auth provider: %s", e) + # Auto-detect Vertex AI via CLAUDE_CODE_USE_VERTEX env var + if os.getenv("CLAUDE_CODE_USE_VERTEX", "").strip() == "1": + project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + if project_id: + return "vertex" + if has_usable_secret(os.getenv("OPENAI_API_KEY")) or has_usable_secret(os.getenv("OPENROUTER_API_KEY")): return "openrouter" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5b180fc29d147..b4600292f19ea 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -922,6 +922,7 @@ def select_provider_and_model(args=None): "copilot-acp": "GitHub Copilot ACP", "copilot": "GitHub Copilot", "anthropic": "Anthropic", + "vertex": "Google Vertex AI (Claude)", "gemini": "Google AI Studio", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", @@ -947,6 +948,7 @@ def select_provider_and_model(args=None): ("nous", "Nous Portal (Nous Research subscription)"), ("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("anthropic", "Anthropic (Claude models — API key or Claude Code)"), + ("vertex", "Google Vertex AI (Claude on GCP — uses gcloud auth)"), ("openai-codex", "OpenAI Codex"), ("qwen-oauth", "Qwen OAuth (reuses local Qwen CLI login)"), ("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"), @@ -1059,6 +1061,8 @@ def select_provider_and_model(args=None): _remove_custom_provider(config) elif selected_provider == "anthropic": _model_flow_anthropic(config, current_model) + elif selected_provider == "vertex": + _model_flow_vertex(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"): @@ -2597,6 +2601,94 @@ def _model_flow_anthropic(config, current_model=""): print("No change.") +def _model_flow_vertex(config, current_model=""): + """Flow for Google Vertex AI provider — uses gcloud ADC, no API key needed.""" + import os + from hermes_cli.auth import _prompt_model_selection, _save_model_choice + from hermes_cli.config import load_config, save_config, save_env_value + from hermes_cli.models import _PROVIDER_MODELS + + # Check current Vertex AI configuration + project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + region = os.getenv("CLOUD_ML_REGION", "").strip() + + print() + print(" Google Vertex AI uses Google Cloud Application Default Credentials.") + print(" Authenticate with: gcloud auth application-default login") + print() + + if project_id: + print(f" Project ID: {project_id} ✓") + if region: + print(f" Region: {region} ✓") + print() + + # Prompt for project ID + if not project_id: + try: + project_id = input(" GCP Project ID (ANTHROPIC_VERTEX_PROJECT_ID): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not project_id: + print(" Cancelled — project ID is required.") + return + else: + try: + new_project = input(f" GCP Project ID [{project_id}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if new_project: + project_id = new_project + + # Prompt for region + default_region = region or "us-east5" + try: + new_region = input(f" Region [{default_region}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + region = new_region or default_region + + # Save env values + save_env_value("ANTHROPIC_VERTEX_PROJECT_ID", project_id) + save_env_value("CLOUD_ML_REGION", region) + save_env_value("CLAUDE_CODE_USE_VERTEX", "1") + os.environ["ANTHROPIC_VERTEX_PROJECT_ID"] = project_id + os.environ["CLOUD_ML_REGION"] = region + os.environ["CLAUDE_CODE_USE_VERTEX"] = "1" + print() + print(f" ✓ Saved: project={project_id}, region={region}") + print() + + # Model selection — Vertex uses the same models as Anthropic + model_list = _PROVIDER_MODELS.get("anthropic", []) + if model_list: + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input(" Model name (e.g., claude-sonnet-4-5-20250929): ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = "vertex" + model.pop("base_url", None) + save_config(cfg) + + print(f" Default model set to: {selected} (via Vertex AI)") + else: + print(" No change.") + + def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 18109e6eaac0b..1d41ee7707701 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -78,6 +78,11 @@ class HermesOverlay: transport="anthropic_messages", extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), ), + "vertex": HermesOverlay( + transport="anthropic_messages", + auth_type="gcloud_adc", + extra_env_vars=("ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION"), + ), "zai": HermesOverlay( transport="openai_chat", extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), @@ -180,6 +185,10 @@ def is_user_defined(self) -> bool: "claude": "anthropic", "claude-code": "anthropic", + # vertex (Google Vertex AI) + "vertex-ai": "vertex", + "google-vertex": "vertex", + # github-copilot (models.dev ID) "copilot": "github-copilot", "github": "github-copilot", @@ -237,6 +246,7 @@ def is_user_defined(self) -> bool: "nous": "Nous Portal", "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", + "vertex": "Google Vertex AI (Claude)", "local": "Local endpoint", } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4457a73552b19..b61477623d616 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -153,6 +153,11 @@ def _resolve_runtime_from_pool_entry( elif provider == "qwen-oauth": api_mode = "chat_completions" base_url = base_url or DEFAULT_QWEN_BASE_URL + elif provider == "vertex": + # Vertex AI uses Google ADC — no API key pooling applies. + api_mode = "anthropic_messages" + base_url = "" + api_key = "" elif provider == "anthropic": api_mode = "anthropic_messages" cfg_provider = str(model_cfg.get("provider") or "").strip().lower() @@ -459,6 +464,25 @@ def _resolve_explicit_runtime( if not explicit_api_key and not explicit_base_url: return None + if provider == "vertex": + project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + region = os.getenv("CLOUD_ML_REGION", "us-east5").strip() + if not project_id: + raise AuthError( + "Vertex AI requires ANTHROPIC_VERTEX_PROJECT_ID. " + "Set it via environment variable or in ~/.hermes/.env." + ) + return { + "provider": "vertex", + "api_mode": "anthropic_messages", + "base_url": "", + "api_key": "", + "project_id": project_id, + "region": region, + "source": "gcloud_adc", + "requested_provider": requested_provider, + } + if provider == "anthropic": cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" @@ -612,7 +636,7 @@ def resolve_runtime_provider( if explicit_runtime: return explicit_runtime - should_use_pool = provider != "openrouter" + should_use_pool = provider not in ("openrouter", "vertex") if provider == "openrouter": cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = str(model_cfg.get("base_url") or "").strip() @@ -727,6 +751,26 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } + # Google Vertex AI (Claude via Anthropic Messages API) + if provider == "vertex": + project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + region = os.getenv("CLOUD_ML_REGION", "us-east5").strip() + if not project_id: + raise AuthError( + "Vertex AI requires ANTHROPIC_VERTEX_PROJECT_ID. " + "Set it via environment variable or in ~/.hermes/.env." + ) + return { + "provider": "vertex", + "api_mode": "anthropic_messages", + "base_url": "", + "api_key": "", + "project_id": project_id, + "region": region, + "source": "gcloud_adc", + "requested_provider": requested_provider, + } + # Anthropic (native Messages API) if provider == "anthropic": from agent.anthropic_adapter import resolve_anthropic_token diff --git a/pyproject.toml b/pyproject.toml index de0e61060c7f3..1311291fdf828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ license = { text = "MIT" } dependencies = [ # Core — pinned to known-good ranges to limit supply chain attack surface "openai>=2.21.0,<3", - "anthropic>=0.39.0,<1", + "anthropic[vertex]>=0.39.0,<1", "python-dotenv>=1.2.1,<2", "fire>=0.7.1,<1", "httpx>=0.28.1,<1", diff --git a/run_agent.py b/run_agent.py index 793ddd6750f0f..1c8de6d605cb0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -512,6 +512,8 @@ def __init__( checkpoint_max_snapshots: int = 50, pass_session_id: bool = False, persist_session: bool = True, + project_id: str = None, + region: str = None, ): """ Initialize the AI Agent. @@ -591,6 +593,8 @@ def __init__( elif (provider_name is None) and "chatgpt.com/backend-api/codex" in self._base_url_lower: self.api_mode = "codex_responses" self.provider = "openai-codex" + elif self.provider == "vertex": + self.api_mode = "anthropic_messages" elif self.provider == "anthropic" or (provider_name is None and "api.anthropic.com" in self._base_url_lower): self.api_mode = "anthropic_messages" self.provider = "anthropic" @@ -741,27 +745,44 @@ def __init__( # access for Codex Responses API streaming. self._anthropic_client = None self._is_anthropic_oauth = False + self._vertex_project_id = "" + self._vertex_region = "" if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_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 (Fixes #1739, #minimax-401). - _is_native_anthropic = self.provider == "anthropic" - effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") - self.api_key = effective_key - self._anthropic_api_key = effective_key - self._anthropic_base_url = base_url - from agent.anthropic_adapter import _is_oauth_token as _is_oat - self._is_anthropic_oauth = _is_oat(effective_key) - self._anthropic_client = build_anthropic_client(effective_key, base_url) - # No OpenAI client needed for Anthropic mode - self.client = None - self._client_kwargs = {} - if not self.quiet_mode: - print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") - if effective_key and len(effective_key) > 12: - print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + if self.provider == "vertex": + from agent.anthropic_adapter import build_vertex_client + self._vertex_project_id = project_id or os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "") + self._vertex_region = region or os.getenv("CLOUD_ML_REGION", "us-east5") + self.api_key = "" + self._anthropic_api_key = "" + self._anthropic_base_url = "" + self._is_anthropic_oauth = False + self._anthropic_client = build_vertex_client(self._vertex_project_id, self._vertex_region) + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (Vertex AI)") + print(f"📍 Project: {self._vertex_project_id}, Region: {self._vertex_region}") + else: + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_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 (Fixes #1739, #minimax-401). + _is_native_anthropic = self.provider == "anthropic" + effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") + self.api_key = effective_key + self._anthropic_api_key = effective_key + self._anthropic_base_url = base_url + from agent.anthropic_adapter import _is_oauth_token as _is_oat + self._is_anthropic_oauth = _is_oat(effective_key) + self._anthropic_client = build_anthropic_client(effective_key, base_url) + # No OpenAI client needed for Anthropic mode + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") + if effective_key and len(effective_key) > 12: + print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") else: if api_key and base_url: # Explicit credentials from CLI/gateway — construct directly. @@ -1252,6 +1273,8 @@ def __init__( "anthropic_api_key": self._anthropic_api_key, "anthropic_base_url": self._anthropic_base_url, "is_anthropic_oauth": self._is_anthropic_oauth, + "vertex_project_id": self._vertex_project_id, + "vertex_region": self._vertex_region, }) def reset_session_state(self): @@ -1334,19 +1357,27 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod # ── Build new client ── if api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - _is_oauth_token, - ) - effective_key = api_key or self.api_key or resolve_anthropic_token() or "" - self.api_key = effective_key - self._anthropic_api_key = effective_key - self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None) - self._anthropic_client = build_anthropic_client( - effective_key, self._anthropic_base_url, - ) - self._is_anthropic_oauth = _is_oauth_token(effective_key) + if new_provider == "vertex": + self._vertex_project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "") or self._vertex_project_id + self._vertex_region = os.getenv("CLOUD_ML_REGION", "us-east5") or self._vertex_region + self._anthropic_api_key = "" + self._anthropic_base_url = "" + self._is_anthropic_oauth = False + self._anthropic_client = self._build_anthropic_client_for_provider() + else: + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + _is_oauth_token, + ) + effective_key = api_key or self.api_key or resolve_anthropic_token() or "" + self.api_key = effective_key + self._anthropic_api_key = effective_key + self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None) + self._anthropic_client = build_anthropic_client( + effective_key, self._anthropic_base_url, + ) + self._is_anthropic_oauth = _is_oauth_token(effective_key) self.client = None self._client_kwargs = {} else: @@ -4103,13 +4134,12 @@ def _try_refresh_anthropic_client_credentials(self) -> bool: except Exception: pass + self._anthropic_api_key = new_token try: - self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None)) + self._anthropic_client = self._build_anthropic_client_for_provider() except Exception as exc: logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc) return False - - self._anthropic_api_key = new_token # Update OAuth flag — token type may have changed (API key ↔ OAuth) from agent.anthropic_adapter import _is_oauth_token self._is_anthropic_oauth = _is_oauth_token(new_token) @@ -4133,11 +4163,14 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None: self._client_kwargs.pop("default_headers", None) def _swap_credential(self, entry) -> None: + if self.provider == "vertex": + return # Vertex uses Google ADC — no credential pooling + runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, _is_oauth_token + from agent.anthropic_adapter import _is_oauth_token try: self._anthropic_client.close() @@ -4146,7 +4179,7 @@ def _swap_credential(self, entry) -> None: self._anthropic_api_key = runtime_key self._anthropic_base_url = runtime_base - self._anthropic_client = build_anthropic_client(runtime_key, runtime_base) + self._anthropic_client = self._build_anthropic_client_for_provider() self._is_anthropic_oauth = _is_oauth_token(runtime_key) if self.provider == "anthropic" else False self.api_key = runtime_key self.base_url = runtime_base @@ -4212,6 +4245,15 @@ def _recover_with_credential_pool( return False, has_retried_429 + def _build_anthropic_client_for_provider(self): + """Build the correct Anthropic client based on the current provider.""" + if self.provider == "vertex": + from agent.anthropic_adapter import build_vertex_client + return build_vertex_client(self._vertex_project_id, self._vertex_region) + else: + from agent.anthropic_adapter import build_anthropic_client + return build_anthropic_client(self._anthropic_api_key, self._anthropic_base_url) + def _anthropic_messages_create(self, api_kwargs: dict): if self.api_mode == "anthropic_messages": self._try_refresh_anthropic_client_credentials() @@ -4260,13 +4302,8 @@ def _call(): # seed future retries. try: if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client - self._anthropic_client.close() - self._anthropic_client = build_anthropic_client( - self._anthropic_api_key, - getattr(self, "_anthropic_base_url", None), - ) + self._anthropic_client = self._build_anthropic_client_for_provider() else: request_client = request_client_holder.get("client") if request_client is not None: @@ -4814,13 +4851,8 @@ def _call(): if self._interrupt_requested: try: if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client - self._anthropic_client.close() - self._anthropic_client = build_anthropic_client( - self._anthropic_api_key, - getattr(self, "_anthropic_base_url", None), - ) + self._anthropic_client = self._build_anthropic_client_for_provider() else: request_client = request_client_holder.get("client") if request_client is not None: @@ -4922,13 +4954,20 @@ def _try_activate_fallback(self) -> bool: if fb_api_mode == "anthropic_messages": # Build native Anthropic client instead of using OpenAI client - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token - effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "") - self.api_key = effective_key - self._anthropic_api_key = effective_key - self._anthropic_base_url = fb_base_url - self._anthropic_client = build_anthropic_client(effective_key, self._anthropic_base_url) - self._is_anthropic_oauth = _is_oauth_token(effective_key) + if fb_provider == "vertex": + self._vertex_project_id = os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "") + self._vertex_region = os.getenv("CLOUD_ML_REGION", "us-east5") + self._anthropic_api_key = "" + self._anthropic_base_url = "" + self._is_anthropic_oauth = False + else: + from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token + effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "") + self.api_key = effective_key + self._anthropic_api_key = effective_key + self._anthropic_base_url = fb_base_url + self._is_anthropic_oauth = _is_oauth_token(effective_key) + self._anthropic_client = self._build_anthropic_client_for_provider() self.client = None self._client_kwargs = {} else: @@ -5008,13 +5047,12 @@ def _restore_primary_runtime(self) -> bool: # ── Rebuild client for the primary provider ── if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client self._anthropic_api_key = rt["anthropic_api_key"] self._anthropic_base_url = rt["anthropic_base_url"] - self._anthropic_client = build_anthropic_client( - rt["anthropic_api_key"], rt["anthropic_base_url"], - ) self._is_anthropic_oauth = rt["is_anthropic_oauth"] + self._vertex_project_id = rt.get("vertex_project_id", "") + self._vertex_region = rt.get("vertex_region", "") + self._anthropic_client = self._build_anthropic_client_for_provider() self.client = None else: self.client = self._create_openai_client( @@ -5102,13 +5140,12 @@ def _try_recover_primary_transport( self.api_key = rt["api_key"] if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client self._anthropic_api_key = rt["anthropic_api_key"] self._anthropic_base_url = rt["anthropic_base_url"] - self._anthropic_client = build_anthropic_client( - rt["anthropic_api_key"], rt["anthropic_base_url"], - ) self._is_anthropic_oauth = rt["is_anthropic_oauth"] + self._vertex_project_id = rt.get("vertex_project_id", "") + self._vertex_region = rt.get("vertex_region", "") + self._anthropic_client = self._build_anthropic_client_for_provider() self.client = None else: self.client = self._create_openai_client( diff --git a/tests/agent/test_vertex_client.py b/tests/agent/test_vertex_client.py new file mode 100644 index 0000000000000..f17559135b03c --- /dev/null +++ b/tests/agent/test_vertex_client.py @@ -0,0 +1,83 @@ +"""Tests for Google Vertex AI client builder in anthropic_adapter.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +class TestBuildVertexClient: + """Tests for build_vertex_client().""" + + def test_raises_when_sdk_missing(self): + """Should raise ImportError when anthropic SDK is not installed.""" + with patch("agent.anthropic_adapter._anthropic_sdk", None): + from agent.anthropic_adapter import build_vertex_client + + with pytest.raises(ImportError, match="anthropic"): + build_vertex_client("my-project", "us-east5") + + def test_raises_when_vertex_extra_missing(self): + """Should raise ImportError when anthropic[vertex] extra is not installed.""" + mock_sdk = MagicMock(spec=[]) # No AnthropicVertex attribute + with patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk): + from agent.anthropic_adapter import build_vertex_client + + with pytest.raises(ImportError, match="vertex"): + build_vertex_client("my-project", "us-east5") + + def test_creates_client_with_correct_params(self): + """Should create AnthropicVertex with project_id, region, timeout, and betas.""" + mock_sdk = MagicMock() + mock_client = MagicMock() + mock_sdk.AnthropicVertex.return_value = mock_client + + with patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk): + from agent.anthropic_adapter import build_vertex_client + + result = build_vertex_client("my-project", "us-central1") + + assert result is mock_client + mock_sdk.AnthropicVertex.assert_called_once() + call_kwargs = mock_sdk.AnthropicVertex.call_args[1] + assert call_kwargs["project_id"] == "my-project" + assert call_kwargs["region"] == "us-central1" + assert "timeout" in call_kwargs + assert "default_headers" in call_kwargs + assert "anthropic-beta" in call_kwargs["default_headers"] + + def test_default_region(self): + """Should default to us-east5 when region not specified.""" + mock_sdk = MagicMock() + with patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk): + from agent.anthropic_adapter import build_vertex_client + + build_vertex_client("my-project") + + call_kwargs = mock_sdk.AnthropicVertex.call_args[1] + assert call_kwargs["region"] == "us-east5" + + def test_no_oauth_betas(self): + """Should NOT include OAuth-only betas (those are for Anthropic direct).""" + mock_sdk = MagicMock() + with patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk): + from agent.anthropic_adapter import build_vertex_client, _OAUTH_ONLY_BETAS + + build_vertex_client("my-project", "us-east5") + + call_kwargs = mock_sdk.AnthropicVertex.call_args[1] + beta_header = call_kwargs["default_headers"]["anthropic-beta"] + for oauth_beta in _OAUTH_ONLY_BETAS: + assert oauth_beta not in beta_header + + def test_no_api_key_param(self): + """Vertex client should not have api_key or auth_token params.""" + mock_sdk = MagicMock() + with patch("agent.anthropic_adapter._anthropic_sdk", mock_sdk): + from agent.anthropic_adapter import build_vertex_client + + build_vertex_client("my-project", "us-east5") + + call_kwargs = mock_sdk.AnthropicVertex.call_args[1] + assert "api_key" not in call_kwargs + assert "auth_token" not in call_kwargs diff --git a/tests/hermes_cli/test_vertex_provider.py b/tests/hermes_cli/test_vertex_provider.py new file mode 100644 index 0000000000000..9c7afd50cd135 --- /dev/null +++ b/tests/hermes_cli/test_vertex_provider.py @@ -0,0 +1,133 @@ +"""Tests for Vertex AI provider registration and resolution.""" + +import os +from unittest.mock import patch + +import pytest + + +class TestVertexProviderRegistry: + """Test that vertex is properly registered in PROVIDER_REGISTRY.""" + + def test_vertex_in_registry(self): + from hermes_cli.auth import PROVIDER_REGISTRY + + assert "vertex" in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["vertex"] + assert pconfig.name == "Google Vertex AI (Claude)" + assert pconfig.auth_type == "gcloud_adc" + assert "ANTHROPIC_VERTEX_PROJECT_ID" in pconfig.api_key_env_vars + + +class TestVertexProviderAliases: + """Test provider alias resolution for vertex.""" + + def test_vertex_ai_alias(self): + from hermes_cli.auth import resolve_provider + + with patch.dict(os.environ, {"ANTHROPIC_VERTEX_PROJECT_ID": "test-project"}): + result = resolve_provider("vertex-ai") + assert result == "vertex" + + def test_google_vertex_alias(self): + from hermes_cli.auth import resolve_provider + + with patch.dict(os.environ, {"ANTHROPIC_VERTEX_PROJECT_ID": "test-project"}): + result = resolve_provider("google-vertex") + assert result == "vertex" + + def test_vertex_direct(self): + from hermes_cli.auth import resolve_provider + + result = resolve_provider("vertex") + assert result == "vertex" + + +class TestVertexAutoDetection: + """Test auto-detection of Vertex AI via CLAUDE_CODE_USE_VERTEX env var.""" + + def test_auto_detects_vertex(self): + from hermes_cli.auth import resolve_provider + + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-project", + } + with patch.dict(os.environ, env, clear=False): + result = resolve_provider("auto") + assert result == "vertex" + + def test_no_detection_without_env(self): + """Without CLAUDE_CODE_USE_VERTEX, should not auto-detect vertex.""" + from hermes_cli.auth import resolve_provider + + env_clear = { + "CLAUDE_CODE_USE_VERTEX": "", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-project", + "OPENROUTER_API_KEY": "sk-or-test", + } + with patch.dict(os.environ, env_clear, clear=False): + result = resolve_provider("auto") + assert result != "vertex" + + def test_no_detection_without_project_id(self): + """With CLAUDE_CODE_USE_VERTEX=1 but no project ID, should not auto-detect.""" + from hermes_cli.auth import resolve_provider + + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "", + "OPENROUTER_API_KEY": "sk-or-test", + } + with patch.dict(os.environ, env, clear=False): + result = resolve_provider("auto") + assert result != "vertex" + + +class TestVertexRuntimeResolution: + """Test runtime provider resolution for vertex.""" + + def test_resolve_vertex_runtime(self): + from hermes_cli.runtime_provider import resolve_runtime_provider + + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-gcp-project", + "CLOUD_ML_REGION": "europe-west1", + } + with patch.dict(os.environ, env, clear=False): + runtime = resolve_runtime_provider(requested="vertex") + + assert runtime["provider"] == "vertex" + assert runtime["api_mode"] == "anthropic_messages" + assert runtime["project_id"] == "my-gcp-project" + assert runtime["region"] == "europe-west1" + assert runtime["source"] == "gcloud_adc" + assert runtime["api_key"] == "" + assert runtime["base_url"] == "" + + def test_default_region(self): + from hermes_cli.runtime_provider import resolve_runtime_provider + + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-project", + } + # Remove CLOUD_ML_REGION if present + clean_env = {k: v for k, v in os.environ.items() if k != "CLOUD_ML_REGION"} + clean_env.update(env) + with patch.dict(os.environ, clean_env, clear=True): + runtime = resolve_runtime_provider(requested="vertex") + + assert runtime["region"] == "us-east5" + + def test_missing_project_id_raises(self): + from hermes_cli.auth import AuthError + from hermes_cli.runtime_provider import resolve_runtime_provider + + env = {"ANTHROPIC_VERTEX_PROJECT_ID": ""} + clean_env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_VERTEX_PROJECT_ID"} + clean_env.update(env) + with patch.dict(os.environ, clean_env, clear=True): + with pytest.raises(AuthError, match="ANTHROPIC_VERTEX_PROJECT_ID"): + resolve_runtime_provider(requested="vertex")