Skip to content
Merged
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
36 changes: 36 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4506,6 +4506,42 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
"directly supported", provider)
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.
try:
from agent.vertex_adapter import get_vertex_config, has_vertex_credentials
except ImportError:
logger.warning("resolve_provider_client: vertex requested but "
"google-auth not installed")
return None, None

if not has_vertex_credentials():
logger.debug("resolve_provider_client: vertex requested but "
"no GCP credentials found")
return None, None

token, base_url = get_vertex_config()
if not token or not base_url:
logger.warning("resolve_provider_client: vertex requested but "
"could not mint token / resolve project")
return None, None

default_model = "google/gemini-3-flash-preview"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
from openai import OpenAI
client = OpenAI(api_key=token, base_url=base_url)
except Exception as exc:
logger.warning("resolve_provider_client: cannot create Vertex "
"client: %s", exc)
return None, None
logger.debug("resolve_provider_client: vertex (%s)", final_model)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))

elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
Expand Down
10 changes: 10 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,16 @@ def _perform_api_call(next_api_kwargs):
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
continue
if (
agent.api_mode == "chat_completions"
and agent.provider == "vertex"
and status_code == 401
and not _retry.vertex_auth_retry_attempted
):
_retry.vertex_auth_retry_attempted = True
if agent._try_refresh_vertex_client_credentials():
agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...")
continue
if (
agent.api_mode == "chat_completions"
and agent.provider == "nous"
Expand Down
1 change: 1 addition & 0 deletions agent/turn_retry_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class TurnRetryState:
nous_auth_retry_attempted: bool = False
nous_paid_entitlement_refresh_attempted: bool = False
copilot_auth_retry_attempted: bool = False
vertex_auth_retry_attempted: bool = False

# ── Format / payload recovery guards ─────────────────────────────────
thinking_sig_retry_attempted: bool = False
Expand Down
5 changes: 5 additions & 0 deletions agent/usage_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,11 @@ def resolve_billing_route(
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# Vertex AI hosts the same Gemini models as Google AI Studio; price them
# off the gemini official-docs snapshot. Strip the "google/" vendor prefix
# the OpenAI-compat endpoint requires so the pricing key matches.
if provider_name == "vertex" or base_url_host_matches(base_url or "", "aiplatform.googleapis.com"):
return BillingRoute(provider="gemini", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"custom", "local"} or (base and "localhost" in base):
return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown")
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
Expand Down
202 changes: 202 additions & 0 deletions agent/vertex_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Vertex AI (Google Cloud) adapter for Hermes Agent.

Provides authentication and configuration for Vertex AI's OpenAI-compatible
endpoint. This allows 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):
GOOGLE_APPLICATION_CREDENTIALS — path to a service account JSON file (secret).
VERTEX_CREDENTIALS_PATH — alias, takes precedence if set (secret).
VERTEX_PROJECT_ID — override the project_id embedded in creds.
VERTEX_REGION — override default region ("global" unless set).

Non-secret routing settings (project_id, region) also live in config.yaml
under the ``vertex:`` section; env vars take precedence over config.yaml.
"""

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

# Ensure google-auth is installed before importing. The [vertex] extra is no
# longer in [all] per the lazy-install policy added 2026-05-12 — lazy_deps
# handles on-demand installation so the Vertex provider still works for users
# who installed plain `hermes-agent` and only later selected a Gemini model.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("provider.vertex", prompt=False)
except Exception:
pass # lazy_deps unavailable or install failed — fall through to the real ImportError below

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 = "global"

_creds_cache: dict = {}


def _vertex_config() -> dict:
"""Return the ``vertex:`` section of config.yaml, or {} on any failure.

Non-secret routing settings (project_id, region) live in config.yaml per
the .env-secrets-only rule. Env vars still take precedence — they are read
directly at the call sites below, with config.yaml as the fallback.
"""
try:
from hermes_cli.config import load_config

section = load_config().get("vertex")
return section if isinstance(section, dict) else {}
except Exception:
return {}


def _resolve_region(explicit: Optional[str] = None) -> str:
"""Region precedence: explicit arg > VERTEX_REGION env > config.yaml > default."""
if explicit:
return explicit
env_region = os.environ.get("VERTEX_REGION", "").strip()
if env_region:
return env_region
cfg_region = str(_vertex_config().get("region") or "").strip()
return cfg_region or DEFAULT_REGION


def _resolve_project_override() -> Optional[str]:
"""Project-ID override precedence: VERTEX_PROJECT_ID env > config.yaml.

Returns None when neither is set (the credentials' embedded project_id
is used in that case).
"""
env_project = os.environ.get("VERTEX_PROJECT_ID", "").strip()
if env_project:
return env_project
cfg_project = str(_vertex_config().get("project_id") or "").strip()
return cfg_project or None


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.
"""
if google is None:
logger.warning("google-auth package not installed. Cannot use Vertex AI.")
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 = _resolve_project_override()
if override_project:
project_id = override_project

return creds.token, project_id
except Exception as e:
logger.error(f"Failed to resolve Vertex AI credentials: {e}")
_creds_cache.pop(cache_key, None)

# If ADC failed (e.g. expired refresh token), try the SA file
# before giving up — it 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.

The `global` location uses a bare `aiplatform.googleapis.com` hostname,
while regional locations use `{region}-aiplatform.googleapis.com`.
Gemini 3.x preview models are only served via the global endpoint at
the time of writing.
"""
host = "aiplatform.googleapis.com" if region == "global" else f"{region}-aiplatform.googleapis.com"
return f"https://{host}/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 = _resolve_region(region)
base_url = build_vertex_base_url(project_id, effective_region)
return token, base_url


def has_vertex_credentials() -> bool:
"""Fast check for whether Vertex credentials appear configured.

No network calls and no google-auth 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
30 changes: 30 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3086,6 +3086,24 @@ def _ensure_hermes_home_managed(home: Path):
},


# Google Vertex AI provider (Gemini via the OpenAI-compatible endpoint).
# Auth is OAuth2 (short-lived access tokens minted from a service-account
# JSON or Application Default Credentials) — NOT a static API key. The
# credential *path* is a secret-adjacent pointer and lives in .env
# (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS); these two
# settings are non-secret routing config and live here. Both are bridged to
# the VERTEX_PROJECT_ID / VERTEX_REGION env vars the adapter reads, so an
# explicit env var still wins over config.yaml.
"vertex": {
# GCP project ID. Empty → use the project_id embedded in the service
# account JSON (or ADC-resolved project).
"project_id": "",
# Vertex region. "global" is required for the Gemini 3.x preview models
# (regional endpoints silently 404 them). Override to a regional value
# (e.g. "us-central1") only if your models are pinned to a region.
"region": "global",
},

# Config schema version - bump this when adding new required fields
"_config_version": 32,
}
Expand Down Expand Up @@ -3155,6 +3173,18 @@ def _ensure_hermes_home_managed(home: Path):
"category": "provider",
"advanced": True,
},
"VERTEX_CREDENTIALS_PATH": {
"description": "Path to a Google Cloud service account JSON for Vertex AI (Gemini). "
"Vertex uses OAuth2, not a static API key — this points at the "
"credentials Hermes mints short-lived tokens from. Falls back to "
"GOOGLE_APPLICATION_CREDENTIALS, then to ADC (gcloud auth "
"application-default login). Set project/region under vertex: in config.yaml.",
"prompt": "Vertex service account JSON path (leave empty to use ADC / GOOGLE_APPLICATION_CREDENTIALS)",
"url": "https://cloud.google.com/iam/docs/keys-create-delete",
"password": False,
"category": "provider",
"advanced": True,
},
"XAI_API_KEY": {
"description": "xAI API key",
"prompt": "xAI API key",
Expand Down
5 changes: 4 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
_model_flow_stepfun,
_model_flow_bedrock_api_key,
_model_flow_bedrock,
_model_flow_vertex,
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
Expand Down Expand Up @@ -3109,6 +3110,8 @@ def _active_custom_key_from_base_url() -> 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 == "azure-foundry":
_model_flow_azure_foundry(config, current_model)
elif selected_provider in {
Expand Down Expand Up @@ -11902,7 +11905,7 @@ def _build_provider_choices() -> list[str]:
# Fallback: static list guarantees the CLI always works
return [
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
"anthropic", "gemini", "xai", "bedrock", "azure-foundry",
"anthropic", "gemini", "vertex", "xai", "bedrock", "azure-foundry",
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
Expand Down
Loading
Loading