diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a2a052d0a8..5dee1209f5 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -162,6 +162,64 @@ def _is_oauth_token(key: str) -> bool: return True +def build_vertex_client(project: str, region: str = "us-east5"): + """Build an AnthropicVertex client using GCP Application Default Credentials. + + Uses the same Messages API as Anthropic direct, but authenticated via + GCP IAM (attached service account, ADC, or explicit key file). + + Args: + project: GCP project ID (required) + region: GCP region where Claude on Vertex AI is generally available + (default: us-east5) + + Returns: + AnthropicVertex client instance (same interface as Anthropic) + """ + try: + from anthropic import AnthropicVertex + except ImportError: + raise ImportError( + "anthropic[vertex] is required for Vertex AI provider. " + "Install with: pip install 'anthropic[vertex]'" + ) + from httpx import Timeout + + kwargs = { + "project_id": project, + "region": region, + "timeout": Timeout(timeout=900.0, connect=10.0), + } + + # If GOOGLE_APPLICATION_CREDENTIALS is set, the SDK uses it automatically. + # Otherwise, ADC picks up the attached service account on GCP VMs. + + # Note: Beta headers are not forwarded to Vertex AI — feature availability + # may differ from the direct Anthropic API. The AnthropicVertex SDK handles + # feature negotiation with the Vertex endpoint. + + return AnthropicVertex(**kwargs) + + +def resolve_vertex_credentials(): + """Resolve Vertex AI credentials from environment. + + Returns: + Tuple of (project_id, region) or (None, None) if not configured. + """ + project = ( + os.environ.get("VERTEX_PROJECT") + or os.environ.get("GOOGLE_CLOUD_PROJECT") + or os.environ.get("GCP_PROJECT_ID") + ) + region = os.environ.get("VERTEX_REGION", "us-east5") + + if not project: + return None, None + + return project, region + + def build_anthropic_client(api_key: str, base_url: str = None): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 0de263c415..c0bc1c4bce 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -59,6 +59,7 @@ "minimax": "MiniMax-M2.7-highspeed", "minimax-cn": "MiniMax-M2.7-highspeed", "anthropic": "claude-haiku-4-5-20251001", + "vertex-ai": "claude-haiku-4-5-20251001", "ai-gateway": "google/gemini-3-flash", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", @@ -512,6 +513,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: continue if provider_id == "anthropic": return _try_anthropic() + if provider_id == "vertex-ai": + return _try_vertex() creds = resolve_api_key_provider_credentials(provider_id) api_key = str(creds.get("api_key", "")).strip() @@ -667,6 +670,23 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL +def _try_vertex() -> Tuple[Optional[Any], Optional[str]]: + """Try to build a Vertex AI auxiliary client (Claude via GCP).""" + try: + from agent.anthropic_adapter import build_vertex_client, resolve_vertex_credentials + except ImportError: + return None, None + + project, region = resolve_vertex_credentials() + if not project: + return None, None + + model = _API_KEY_PROVIDER_AUX_MODELS.get("vertex-ai", "claude-haiku-4-5-20251001") + logger.debug("Auxiliary client: Vertex AI (%s) project=%s region=%s", model, project, region) + real_client = build_vertex_client(project, region) + return AnthropicAuxiliaryClient(real_client, model, "", ""), model + + def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: try: from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token @@ -968,6 +988,14 @@ def resolve_provider_client( final_model = model or default_model return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + if provider == "vertex-ai": + client, default_model = _try_vertex() + if client is None: + logger.warning("resolve_provider_client: vertex-ai requested but no GCP credentials found (set VERTEX_PROJECT)") + return None, None + final_model = model or default_model + return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + creds = resolve_api_key_provider_credentials(provider) api_key = str(creds.get("api_key", "")).strip() if not api_key: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 940a155645..cf67e8ef02 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -156,6 +156,13 @@ class ProviderConfig: inference_base_url="https://api.anthropic.com", api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), ), + "vertex-ai": ProviderConfig( + id="vertex-ai", + name="Google Vertex AI (Claude)", + auth_type="api_key", + inference_base_url="", # Region-dependent, resolved at runtime + api_key_env_vars=("VERTEX_PROJECT",), # Uses ADC, not API key + ), "alibaba": ProviderConfig( id="alibaba", name="Alibaba Cloud (DashScope)", diff --git a/pyproject.toml b/pyproject.toml index 38974e3287..f93442c074 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ mcp = ["mcp>=1.2.0,<2"] homeassistant = ["aiohttp>=3.9.0,<4"] sms = ["aiohttp>=3.9.0,<4"] acp = ["agent-client-protocol>=0.8.1,<0.9"] +vertex = ["anthropic[vertex]>=0.39.0,<1"] dingtalk = ["dingtalk-stream>=0.1.0,<1"] feishu = ["lark-oapi>=1.5.3,<2"] rl = [ @@ -85,6 +86,7 @@ all = [ "hermes-agent[acp]", "hermes-agent[voice]", "hermes-agent[dingtalk]", + "hermes-agent[vertex]", "hermes-agent[feishu]", ] diff --git a/run_agent.py b/run_agent.py index 30453c01ce..9489e7eb76 100644 --- a/run_agent.py +++ b/run_agent.py @@ -589,6 +589,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-ai": + 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" @@ -782,7 +784,21 @@ def __init__( self._anthropic_client = None self._is_anthropic_oauth = False - if self.api_mode == "anthropic_messages": + if self.api_mode == "anthropic_messages" and self.provider == "vertex-ai": + from agent.anthropic_adapter import build_vertex_client, resolve_vertex_credentials + project, region = resolve_vertex_credentials() + if not project: + raise ValueError("VERTEX_PROJECT environment variable is required for vertex-ai provider") + self._anthropic_client = build_vertex_client(project, region) + self._anthropic_api_key = "" + self._anthropic_base_url = "" + self._is_anthropic_oauth = False + self.api_key = "" + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (Vertex AI, project={project}, region={region})") + elif 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.