Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
"minimax_cn": "minimax-cn",
"claude": "anthropic",
"claude-code": "anthropic",
"vertex-ai": "vertex",
"google-vertex": "vertex",
}


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})


Expand Down
1 change: 1 addition & 0 deletions agent/models_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}


Expand Down
14 changes: 14 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"

Expand Down
92 changes: 92 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)"),
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Project ID and region are non-secret routing settings. Persist them under the existing vertex: config section rather than .env, matching the repository configuration policy and current Vertex provider.

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
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
}

Expand Down
Loading