Skip to content
Closed
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
5 changes: 4 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2471,7 +2471,10 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
if resolved_provider not in _AGGREGATOR_PROVIDERS:
normalized_model = normalize_model_for_provider(current_model, resolved_provider)
if normalized_model and normalized_model != current_model:
if not self._model_is_default:
# Vertex's OpenAPI endpoint requires a publisher prefix (google/*)
# — adding it silently is correct behaviour, not a user error.
_silent_prefix = (resolved_provider == "vertex" and normalized_model == f"google/{current_model}")
if not self._model_is_default and not _silent_prefix:
self._console_print(
f"[yellow]⚠️ Normalized model '{current_model}' to '{normalized_model}' for {resolved_provider}.[/]"
)
Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,14 @@ class ProviderConfig:
api_key_env_vars=(),
base_url_env_var="BEDROCK_BASE_URL",
),
"vertex": ProviderConfig(
id="vertex",
name="Google Vertex AI",
auth_type="api_key",
inference_base_url="",
api_key_env_vars=("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"),
base_url_env_var="VERTEX_REGION",
)
}


Expand Down
85 changes: 85 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,8 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
_model_flow_stepfun(config, current_model)
elif selected_provider == "bedrock":
_model_flow_bedrock(config, current_model)
elif selected_provider == "vertex":
_model_flow_vertex(config, current_model)
elif selected_provider in (
"gemini",
"deepseek",
Expand Down Expand Up @@ -3965,6 +3967,89 @@ def _sort_key(m):
print(" No change.")


def _model_flow_vertex(config, current_model=""):
"""Google Vertex AI provider flow.

Credentials come from a service account JSON file or ADC — no API key
prompt. Region is the only optional override.
"""
from hermes_cli.auth import (
_prompt_model_selection,
_save_model_choice,
deactivate_provider,
)
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
from hermes_cli.models import _PROVIDER_MODELS
from hermes_cli.vertex_adapter import get_vertex_config, DEFAULT_REGION

# 1. Resolve credentials (SA file or ADC) — just show status, don't prompt
creds_path = (
get_env_value("VERTEX_CREDENTIALS_PATH")
or os.getenv("VERTEX_CREDENTIALS_PATH", "")
or get_env_value("GOOGLE_APPLICATION_CREDENTIALS")
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "")
)
if creds_path:
short = creds_path if len(creds_path) <= 40 else f"...{creds_path[-37:]}"
print(f" Credentials: {short} ✓")
else:
print(" Credentials: Application Default Credentials (ADC)")
print(" (Set VERTEX_CREDENTIALS_PATH or GOOGLE_APPLICATION_CREDENTIALS")
print(" to a service account JSON file, or run")
print(" `gcloud auth application-default login`.)")
print()

# Quick probe to catch missing google-auth or invalid creds early
token, _ = get_vertex_config(credentials_path=creds_path or None)
if not token:
print(" ✗ Could not obtain a Vertex AI access token.")
print(" Check that google-auth is installed and credentials are valid.")
return

# 2. Optional region override
current_region = (
get_env_value("VERTEX_REGION")
or os.getenv("VERTEX_REGION", "")
or get_env_value("VERTEX_LOCATION")
or os.getenv("VERTEX_LOCATION", "")
or DEFAULT_REGION
)
try:
region_input = input(f" Region [{current_region}]: ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
region = region_input or current_region
if region != current_region:
save_env_value("VERTEX_REGION", region)
print(f" Region saved: {region}")
print()

# 3. Model selection
model_list = _PROVIDER_MODELS.get("vertex", [])
if not model_list:
model_list = ["gemini-2.5-pro", "gemini-2.5-flash"]

selected = _prompt_model_selection(model_list, current_model)
if not selected:
print(" No change.")
return

_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)
model.pop("api_mode", None)
save_config(cfg)
deactivate_provider()
print(f" Default model set to: {selected} (via Google Vertex AI, {region})")


def _model_flow_api_key_provider(config, provider_id, current_model=""):
"""Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.)."""
from hermes_cli.auth import (
Expand Down
7 changes: 7 additions & 0 deletions hermes_cli/model_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,13 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
# --- Aggregators: need vendor/model format ---
if provider in _AGGREGATOR_PROVIDERS:
return _prepend_vendor(name)

# --- Vertex AI: OpenAPI endpoint strictly requires publisher prefix ---
if provider == "vertex":
bare = _strip_matching_provider_prefix(name, provider)
if not bare.startswith("google/"):
return f"google/{bare}"
return bare

# --- OpenCode Zen: Claude stays hyphenated; other models keep dots ---
if provider == "opencode-zen":
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@ def _codex_curated_models() -> list[str]:
"us.meta.llama4-maverick-17b-instruct-v1:0",
"us.meta.llama4-scout-17b-instruct-v1:0",
],
# Google vertex available models supported by the ChatCompletions API
"vertex": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-001",
]
}

# Vercel AI Gateway: derive the bare-model-id catalog from the curated
Expand Down Expand Up @@ -740,6 +748,7 @@ class ProviderEntry(NamedTuple):
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"),
ProviderEntry("vertex", "Vertex AI", "Google Vertex AI (GCP Service Account JSON)"),
]

# Derived dicts — used throughout the codebase
Expand Down Expand Up @@ -2510,6 +2519,8 @@ def validate_requested_model(
requested,
api_key=api_key,
) or requested
elif normalized == "vertex" and requested_for_lookup.startswith("google/"):
requested_for_lookup = requested_for_lookup[len("google/"):]

if not requested:
return {
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ class HermesOverlay:
auth_type="oauth_external",
base_url_override="cloudcode-pa://google",
),
"vertex": HermesOverlay(
transport="openai_chat",
auth_type="api_key",
extra_env_vars=("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"),
base_url_env_var="VERTEX_REGION",
),
"copilot-acp": HermesOverlay(
transport="codex_responses",
auth_type="external_process",
Expand Down Expand Up @@ -271,6 +277,10 @@ class ProviderDef:
"gemini-cli": "google-gemini-cli",
"gemini-oauth": "google-gemini-cli",

# google vertex
"vertex-ai": "vertex",
"google-vertex": "vertex",


# huggingface
"hf": "huggingface",
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,19 @@ def _resolve_runtime_from_pool_entry(
if cfg_provider == "anthropic":
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
base_url = cfg_base_url or base_url or "https://api.anthropic.com"
elif provider == "vertex":
api_mode = "chat_completions"
from hermes_cli.vertex_adapter import get_vertex_config
region = base_url or None # base_url slot carries the region when set explicitly
token, resolved_url = get_vertex_config(credentials_path=api_key or None, region=region)
if not token or not resolved_url:
raise ValueError(
"Vertex AI credentials not found. Set VERTEX_CREDENTIALS_PATH or "
"GOOGLE_APPLICATION_CREDENTIALS to a service account JSON file, "
"or run `gcloud auth application-default login`."
)
api_key = token
base_url = resolved_url
elif provider == "openrouter":
base_url = base_url or OPENROUTER_BASE_URL
elif provider == "xai":
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ def _supports_same_provider_pool_setup(provider: str) -> bool:
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
],
"vertex": [
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
"gemini-2.0-flash-001", "gemini-2.0-flash-lite-001",
],
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
Expand Down
137 changes: 137 additions & 0 deletions hermes_cli/vertex_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Vertex AI (Google Cloud) adapter for Hermes CLI.

Provides authentication and configuration for Vertex AI's OpenAI-compatible
endpoint, allowing Hermes to use Gemini models via Google Cloud with
enterprise-grade rate limits and quotas.

Requires: pip install google-auth

Environment variables honored (all optional — ADC is used as a fallback):
VERTEX_CREDENTIALS_PATH — path to a service account JSON file (takes precedence).
GOOGLE_APPLICATION_CREDENTIALS — standard GCP credential path.
VERTEX_PROJECT_ID — override the project_id embedded in creds.
VERTEX_REGION / VERTEX_LOCATION — override default region ("us-central1" unless set).
"""

import logging
import os
import time
from typing import Optional, Tuple

try:
import google.auth
import google.auth.transport.requests
from google.oauth2 import service_account
except ImportError:
google = None # type: ignore[assignment]

logger = logging.getLogger(__name__)

DEFAULT_REGION = "us-central1"
_creds_cache: dict = {}


def _resolve_credentials_path(explicit: Optional[str]) -> Optional[str]:
if explicit and os.path.exists(explicit):
return explicit
for env_var in ("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"):
path = os.environ.get(env_var)
if path and os.path.exists(path):
return path
return None


def _refresh_credentials(creds) -> None:
auth_req = google.auth.transport.requests.Request()
creds.refresh(auth_req)


def get_vertex_credentials(credentials_path: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
"""Return a (fresh access_token, project_id) pair or (None, None) on failure.

Caches the underlying Credentials object and refreshes it when within
5 minutes of expiry, so repeated calls don't thrash the token endpoint.
Supports both service account files and Application Default Credentials.
"""
if google is None:
logger.warning(
"google-auth package not installed. "
"Install it via: pip install hermes-agent[vertex]"
)
return None, None

resolved_path = _resolve_credentials_path(credentials_path)
cache_key = resolved_path or "__adc__"

try:
cached = _creds_cache.get(cache_key)
if cached is None:
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"]
)
_creds_cache[cache_key] = (creds, project_id)
else:
creds, project_id = cached

needs_refresh = (
not getattr(creds, "token", None)
or getattr(creds, "expired", False)
or (
getattr(creds, "expiry", None) is not None
and (creds.expiry.timestamp() - time.time()) < 300
)
)
if needs_refresh:
_refresh_credentials(creds)

override_project = os.environ.get("VERTEX_PROJECT_ID")
if override_project:
project_id = override_project

return creds.token, project_id

except Exception as e:
logger.error("Failed to resolve Vertex AI credentials: %s", e)
_creds_cache.pop(cache_key, None)
# If ADC failed, retry with a service account file that may have been
# added after initial startup.
if cache_key == "__adc__":
sa_path = _resolve_credentials_path(credentials_path)
if sa_path:
logger.info("ADC failed, retrying with service account: %s", sa_path)
return get_vertex_credentials(sa_path)
return None, None


def build_vertex_base_url(project_id: str, region: str = DEFAULT_REGION) -> str:
"""Build the OpenAI-compatible base URL for Vertex AI."""
return (
f"https://{region}-aiplatform.googleapis.com"
f"/v1beta1/projects/{project_id}/locations/{region}/endpoints/openapi"
)


def get_vertex_config(
credentials_path: Optional[str] = None,
region: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Resolve (access_token, base_url) for Vertex AI, or (None, None) on failure."""
token, project_id = get_vertex_credentials(credentials_path)
if not token or not project_id:
return None, None

effective_region = (
region
or os.environ.get("VERTEX_REGION")
or os.environ.get("VERTEX_LOCATION")
or DEFAULT_REGION
)
base_url = build_vertex_base_url(project_id, effective_region)
return token, base_url
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ google = [
"google-auth-oauthlib>=1.0,<2",
"google-auth-httplib2>=0.2,<1",
]
vertex = ["google-auth>=2.0.0,<3", "requests>=2.0.0,<3"]
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [
Expand Down
2 changes: 1 addition & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,7 @@ def __init__(
# but no credentials were found, fail fast with a clear
# message instead of silently routing through OpenRouter.
_explicit = (self.provider or "").strip().lower()
if _explicit and _explicit not in ("auto", "openrouter", "custom"):
if _explicit and _explicit not in ("auto", "openrouter", "custom", "vertex"):
# Look up the actual env var name from the provider
# config — some providers use non-standard names
# (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY).
Expand Down
Loading