mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(vertex): add Google Vertex AI provider for Gemini (OAuth2)
Adds Vertex AI as a first-class provider for Gemini models via Vertex's OpenAI-compatible endpoint. Vertex authenticates with short-lived OAuth2 access tokens (service-account JSON or ADC), not a static API key — the missing piece behind the recurring requests (#13484, #12639, #56259). - agent/vertex_adapter.py: OAuth2 token minting + refresh-on-expiry (5-min margin), ADC->service-account fallback, global vs regional endpoint URLs. Config precedence: env var > config.yaml > default. - plugins/model-providers/vertex/: provider profile (auth_type=vertex), reuses Gemini's extra_body.google.thinking_config translation. - runtime_provider: vertex short-circuit BEFORE the credential pool so a credentials-file path is never mistaken for a static API key; mints a fresh token + computes base_url per resolve. - run_agent + conversation_loop: _try_refresh_vertex_client_credentials() re-mints the token and rebuilds the client on a mid-session 401, so a long-lived gateway agent survives token expiry (~1h). - auxiliary_client: vertex auth_type branch for side-LLM tasks. - config.yaml: vertex.project_id / vertex.region (non-secret, bridged to env); credential path stays in .env (VERTEX_CREDENTIALS_PATH). - setup wizard + model picker: dedicated _model_flow_vertex; curated google/gemini-* model list; --provider choices. - pricing/metadata: Vertex prices off the gemini docs snapshot; endpoint host auto-maps to the vertex provider (no probe spam). - lazy_deps + pyproject [vertex] extra: google-auth, opt-in only. - docs: guides/google-vertex.md + providers page; tests for adapter + runtime resolution. Salvages and modernizes #8427 by @slawt onto current main: rewired from the legacy PROVIDER_REGISTRY path to the provider-profile architecture, moved non-secret config out of .env into config.yaml, and added the per-turn 401 token-refresh the original lacked.
This commit is contained in:
parent
a4af257a6d
commit
c73e74386b
24 changed files with 1014 additions and 7 deletions
|
|
@ -4506,6 +4506,42 @@ def resolve_provider_client(
|
|||
"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).
|
||||
|
|
|
|||
|
|
@ -2598,6 +2598,16 @@ def run_conversation(
|
|||
_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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
202
agent/vertex_adapter.py
Normal file
202
agent/vertex_adapter.py
Normal file
|
|
@ -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
|
||||
|
|
@ -3087,6 +3087,24 @@ DEFAULT_CONFIG = {
|
|||
},
|
||||
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
|
@ -3156,6 +3174,18 @@ OPTIONAL_ENV_VARS = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ from hermes_cli.model_setup_flows import (
|
|||
_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,
|
||||
|
|
@ -3109,6 +3110,8 @@ def select_provider_and_model(args=None):
|
|||
_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 {
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2316,6 +2316,110 @@ def _model_flow_bedrock(config, current_model=""):
|
|||
else:
|
||||
print(" No change.")
|
||||
|
||||
|
||||
def _model_flow_vertex(config, current_model=""):
|
||||
"""Google Vertex AI provider: Gemini via the OpenAI-compatible endpoint.
|
||||
|
||||
Auth is OAuth2 — short-lived tokens minted from a service-account JSON or
|
||||
Application Default Credentials (ADC). No static API key. The credential
|
||||
*path* lives in .env (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS);
|
||||
project ID and region are non-secret and saved to config.yaml under vertex:.
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config, get_env_value
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
# 1. Credential source detection (fast, no network / no google-auth import).
|
||||
sa_path = (
|
||||
get_env_value("VERTEX_CREDENTIALS_PATH")
|
||||
or get_env_value("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
or ""
|
||||
).strip()
|
||||
if sa_path:
|
||||
print(f" Vertex credentials: service account JSON ({sa_path}) ✓")
|
||||
else:
|
||||
print(" Vertex credentials: Application Default Credentials (ADC)")
|
||||
print(" Vertex uses OAuth2, not a static API key. Either:")
|
||||
print(" • run 'gcloud auth application-default login', or")
|
||||
print(" • set VERTEX_CREDENTIALS_PATH in ~/.hermes/.env to a service account JSON")
|
||||
print()
|
||||
|
||||
cfg = load_config()
|
||||
vertex_cfg = cfg.get("vertex")
|
||||
if not isinstance(vertex_cfg, dict):
|
||||
vertex_cfg = {}
|
||||
|
||||
# 2. Project ID (optional — falls back to the project embedded in creds).
|
||||
current_project = str(vertex_cfg.get("project_id") or "").strip()
|
||||
try:
|
||||
project_input = input(
|
||||
f" GCP project ID [{current_project or 'from credentials'}]: "
|
||||
).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
project_id = project_input or current_project
|
||||
|
||||
# 3. Region (default global — required for the Gemini 3.x previews).
|
||||
current_region = str(vertex_cfg.get("region") or "global").strip() or "global"
|
||||
try:
|
||||
region_input = input(f" Vertex region [{current_region}]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
region = region_input or current_region
|
||||
|
||||
# 4. Model selection (curated list — Vertex has no /models listing route).
|
||||
model_list = _PROVIDER_MODELS.get("vertex", []) or [
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash-preview",
|
||||
]
|
||||
base_url_preview = (
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/<project>/"
|
||||
f"locations/{region}/endpoints/openapi"
|
||||
if region == "global"
|
||||
else f"https://{region}-aiplatform.googleapis.com/v1beta1/projects/<project>/"
|
||||
f"locations/{region}/endpoints/openapi"
|
||||
)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="vertex",
|
||||
confirm_base_url=base_url_preview,
|
||||
)
|
||||
|
||||
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"
|
||||
# base_url is computed at runtime from project+region; do not pin it.
|
||||
model.pop("base_url", None)
|
||||
model.pop("api_mode", None) # chat_completions is the profile default
|
||||
clear_model_endpoint_credentials(model, clear_api_mode=False)
|
||||
|
||||
vcfg = cfg.get("vertex")
|
||||
if not isinstance(vcfg, dict):
|
||||
vcfg = {}
|
||||
vcfg["project_id"] = project_id
|
||||
vcfg["region"] = region
|
||||
cfg["vertex"] = vcfg
|
||||
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
print(f" Default model set to: {selected} (via Google Vertex AI, {region})")
|
||||
else:
|
||||
print(" No change.")
|
||||
|
||||
def _select_zai_endpoint(current_base: str) -> str:
|
||||
"""Present a picker for Z.AI endpoint selection during setup.
|
||||
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"),
|
||||
ProviderEntry("vertex", "Google Vertex AI", "Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"),
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"),
|
||||
|
|
@ -1062,7 +1063,7 @@ try:
|
|||
for _pp in _list_providers_for_canonical():
|
||||
if _pp.name in _canonical_slugs:
|
||||
continue
|
||||
if _pp.auth_type in {"oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"}:
|
||||
if _pp.auth_type in {"oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot", "vertex"}:
|
||||
continue # non-api-key flows need bespoke picker UX; skip auto-inject
|
||||
_label = _pp.display_name or _pp.name
|
||||
_desc = _pp.description or f"{_label} (direct API)"
|
||||
|
|
@ -1193,6 +1194,10 @@ _PROVIDER_ALIASES = {
|
|||
"google": "gemini",
|
||||
"google-gemini": "gemini",
|
||||
"google-ai-studio": "gemini",
|
||||
"google-vertex": "vertex",
|
||||
"vertex-ai": "vertex",
|
||||
"gcp-vertex": "vertex",
|
||||
"vertexai": "vertex",
|
||||
"kimi": "kimi-coding",
|
||||
"moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn",
|
||||
|
|
|
|||
|
|
@ -1540,6 +1540,39 @@ def resolve_runtime_provider(
|
|||
)
|
||||
return azure_runtime
|
||||
|
||||
# Vertex AI: OAuth2-token provider (Gemini via the OpenAI-compatible
|
||||
# endpoint). Resolve BEFORE the custom-runtime / credential-pool / generic
|
||||
# paths. The credential *path* (GOOGLE_APPLICATION_CREDENTIALS /
|
||||
# VERTEX_CREDENTIALS_PATH) must never reach the credential pool or the
|
||||
# generic api_key resolver — those would treat the file path as a static
|
||||
# API key. Instead we mint a short-lived OAuth2 access token here and hand
|
||||
# it to the standard OpenAI client as api_key, with base_url computed from
|
||||
# the project ID + region. The token is re-minted per call (5-min refresh
|
||||
# margin) by get_vertex_config(); mid-session expiry is additionally
|
||||
# recovered on 401 by run_agent._try_refresh_vertex_client_credentials().
|
||||
if requested_provider in ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"):
|
||||
from agent.vertex_adapter import get_vertex_config
|
||||
|
||||
token, base_url = get_vertex_config()
|
||||
if not token or not base_url:
|
||||
raise AuthError(
|
||||
"Vertex AI credentials could not be resolved. Vertex uses "
|
||||
"OAuth2 (not a static API key): provide a service-account JSON "
|
||||
"via GOOGLE_APPLICATION_CREDENTIALS (or VERTEX_CREDENTIALS_PATH) "
|
||||
"in ~/.hermes/.env, or run 'gcloud auth application-default "
|
||||
"login' for ADC. Set the GCP project/region under vertex: in "
|
||||
"config.yaml if they aren't embedded in the credentials. "
|
||||
"Install the extra with: pip install 'hermes-agent[vertex]'."
|
||||
)
|
||||
return {
|
||||
"provider": "vertex",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_key": token,
|
||||
"source": "vertex-oauth",
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
custom_runtime = _resolve_named_custom_runtime(
|
||||
requested_provider=requested_provider,
|
||||
explicit_api_key=explicit_api_key,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ _DEFAULT_PROVIDER_MODELS = {
|
|||
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"vertex": [
|
||||
"google/gemini-3.1-pro-preview", "google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash-preview", "google/gemini-3.1-flash-lite-preview",
|
||||
"google/gemini-2.5-pro", "google/gemini-2.5-flash",
|
||||
],
|
||||
"zai": ["glm-5.2", "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"],
|
||||
|
|
|
|||
75
plugins/model-providers/vertex/__init__.py
Normal file
75
plugins/model-providers/vertex/__init__.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Google Vertex AI provider profile.
|
||||
|
||||
vertex: Gemini models via Google Cloud's OpenAI-compatible endpoint.
|
||||
|
||||
Auth is OAuth2 — short-lived access tokens minted from a service-account JSON
|
||||
or Application Default Credentials (ADC), NOT a static API key. Token
|
||||
resolution and refresh live in ``agent/vertex_adapter.py``; runtime_provider.py
|
||||
calls it to obtain a fresh ``(token, base_url)`` pair, then hands the token to
|
||||
the standard OpenAI client as ``api_key``. Because the wire format is the
|
||||
OpenAI-compatible chat/completions surface, no message translation is needed —
|
||||
the only Gemini-specific concern is the ``thinking_config`` reasoning hook,
|
||||
which is emitted here exactly as the ``gemini`` provider does for its
|
||||
OpenAI-compat subpath (``extra_body.google.thinking_config``).
|
||||
|
||||
``auth_type="vertex"`` marks this as an OAuth-token provider (resolved
|
||||
specially, like bedrock's ``aws_sdk``) so it is never treated as an
|
||||
api_key provider that would mistake a credentials-file path for a key.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
class VertexProfile(ProviderProfile):
|
||||
"""Vertex AI — reuse Gemini's thinking_config translation for extra_body."""
|
||||
|
||||
def build_extra_body(
|
||||
self, *, session_id: str | None = None, **context: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Emit ``extra_body.google.thinking_config`` for the OpenAI-compat
|
||||
Vertex surface, mirroring the ``gemini`` provider's behavior.
|
||||
"""
|
||||
from agent.transports.chat_completions import (
|
||||
_build_gemini_thinking_config,
|
||||
_snake_case_gemini_thinking_config,
|
||||
)
|
||||
|
||||
model = context.get("model") or ""
|
||||
reasoning_config = context.get("reasoning_config")
|
||||
|
||||
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
|
||||
if not raw_thinking_config:
|
||||
return {}
|
||||
|
||||
thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
|
||||
if not thinking_config:
|
||||
return {}
|
||||
return {"extra_body": {"google": {"thinking_config": thinking_config}}}
|
||||
|
||||
def fetch_models(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout: float = 8.0,
|
||||
) -> list[str] | None:
|
||||
"""Vertex's OpenAI-compat endpoint has no ``/models`` listing route;
|
||||
model discovery is not available. The setup wizard ships a curated list.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
vertex = VertexProfile(
|
||||
name="vertex",
|
||||
aliases=("google-vertex", "vertex-ai", "gcp-vertex"),
|
||||
api_mode="chat_completions",
|
||||
env_vars=(), # OAuth2 via service account / ADC — not a static key env var
|
||||
base_url="https://aiplatform.googleapis.com", # real base_url computed at runtime
|
||||
auth_type="vertex",
|
||||
default_aux_model="google/gemini-3-flash-preview",
|
||||
)
|
||||
|
||||
register_provider(vertex)
|
||||
5
plugins/model-providers/vertex/plugin.yaml
Normal file
5
plugins/model-providers/vertex/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name: vertex-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Google Vertex AI (Gemini via OpenAI-compatible endpoint, OAuth2)
|
||||
author: Steve Lawton (@slawt), Hermes Agent
|
||||
|
|
@ -225,6 +225,7 @@ acp = ["agent-client-protocol==0.9.0"]
|
|||
# installs (see [all] policy comment below).
|
||||
mistral = ["mistralai==2.4.8"]
|
||||
bedrock = ["boto3==1.42.89"]
|
||||
vertex = ["google-auth==2.55.1"]
|
||||
azure-identity = ["azure-identity==1.25.3"]
|
||||
termux = [
|
||||
# Baseline Android / Termux path for reliable fresh installs.
|
||||
|
|
|
|||
42
run_agent.py
42
run_agent.py
|
|
@ -4214,6 +4214,43 @@ class AIAgent:
|
|||
|
||||
return True
|
||||
|
||||
def _try_refresh_vertex_client_credentials(self) -> bool:
|
||||
"""Re-mint the Vertex OAuth2 access token and rebuild the OpenAI client.
|
||||
|
||||
Vertex tokens live ~1 hour. On a long-lived agent (gateway session) a
|
||||
cached client's bearer token will expire mid-session, producing a 401.
|
||||
This re-resolves credentials via the adapter (which refreshes the
|
||||
underlying google-auth Credentials object when near expiry), swaps the
|
||||
new token into the client kwargs, and rebuilds the primary OpenAI
|
||||
client. Returns True when a usable token+base_url were obtained.
|
||||
"""
|
||||
if self.api_mode != "chat_completions" or self.provider != "vertex":
|
||||
return False
|
||||
|
||||
try:
|
||||
from agent.vertex_adapter import get_vertex_config
|
||||
|
||||
token, base_url = get_vertex_config()
|
||||
except Exception as exc:
|
||||
logger.debug("Vertex credential refresh failed: %s", exc)
|
||||
return False
|
||||
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
return False
|
||||
if not isinstance(base_url, str) or not base_url.strip():
|
||||
return False
|
||||
|
||||
self.api_key = token.strip()
|
||||
self.base_url = base_url.strip().rstrip("/")
|
||||
self._client_kwargs["api_key"] = self.api_key
|
||||
self._client_kwargs["base_url"] = self.base_url
|
||||
|
||||
if not self._replace_primary_openai_client(reason="vertex_credential_refresh"):
|
||||
return False
|
||||
|
||||
logger.info("Vertex AI OAuth token refreshed")
|
||||
return True
|
||||
|
||||
def _try_refresh_copilot_client_credentials(self) -> bool:
|
||||
"""Refresh Copilot credentials and rebuild the shared OpenAI client.
|
||||
|
||||
|
|
@ -5122,7 +5159,7 @@ class AIAgent:
|
|||
"alibaba", "minimax", "minimax-cn",
|
||||
"opencode-go", "opencode-zen",
|
||||
"zai", "bedrock",
|
||||
"xiaomi",
|
||||
"xiaomi", "vertex",
|
||||
}:
|
||||
return True
|
||||
base = (getattr(self, "base_url", "") or "").lower()
|
||||
|
|
@ -5133,6 +5170,9 @@ class AIAgent:
|
|||
or "opencode.ai/zen/" in base
|
||||
or "bigmodel.cn" in base
|
||||
or "xiaomimimo.com" in base
|
||||
# Vertex AI OpenAI-compat endpoint — Gemini model ids keep dots
|
||||
# (e.g. google/gemini-3.5-flash); the hyphenated form is wrong.
|
||||
or "aiplatform.googleapis.com" in base
|
||||
# AWS Bedrock runtime endpoints — defense-in-depth when
|
||||
# ``provider`` is unset but ``base_url`` still names Bedrock.
|
||||
or "bedrock-runtime." in base
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ AUTHOR_MAP = {
|
|||
"brett@personalfinancelab.com": "brett539", # PR #49369 salvage (cap Telegram initialize() with asyncio.wait_for(HERMES_TELEGRAM_INIT_TIMEOUT, default 30s) per attempt so an unreachable fallback-IP connect chain can't block gateway startup indefinitely; add WARNING progress logs before DoH discovery and each connect attempt)
|
||||
"randomuser2026x@proton.me": "randomuser2026x", # PR #50204 salvage (gateway /restart under systemd: probe both system + --user scope for MainPID instead of hardcoding --user; always exit 75 so RestartForceExitStatus=75 revives the unit under Restart=on-failure too, not just Restart=always)
|
||||
"mac-studio@Fabios-Mac-Studio.local": "valenteff", # PR #53277 salvage (macOS launchd reload: retry bootstrap via _launchctl_bootstrap until launchctl-list confirms registration or the restart-drain window elapses; retry TimeoutExpired not just CalledProcessError; log persistent orphans)
|
||||
"steve@lightpathapps.com": "slawt", # PR #8427 salvage (Google Vertex AI provider for Gemini: OAuth2 token minting via service-account JSON / ADC on the OpenAI-compat endpoint, rewired as a provider profile with per-turn 401 token refresh)
|
||||
"gary@bitcryptic.com": "bitcryptic-gw", # PR #53997 salvage (Matrix E2EE: resolve device_id via query_keys({mxid: []}) when whoami returns none; guard verification call sites so query_keys is never sent [null]; reset _device_id_unverified at connect() start; disconnect before reconnect)
|
||||
"hodlclone@gmail.com": "HODLCLONE", # PR #49351 salvage (Nous Portal token resilience: rotate refresh tokens write-through to the source auth store in profile mode, skip Nous fallback when no local token, sync gateway session model after fallback)
|
||||
"7698789+abchiaravalle@users.noreply.github.com": "abchiaravalle", # PR #46997 salvage (recover resume_pending sessions: dual freshness signal + empty-turn safety net so restart auto-resume never sends a blank user turn)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ EXPECTED_FIELDS = {
|
|||
"nous_auth_retry_attempted",
|
||||
"nous_paid_entitlement_refresh_attempted",
|
||||
"copilot_auth_retry_attempted",
|
||||
"vertex_auth_retry_attempted",
|
||||
"thinking_sig_retry_attempted",
|
||||
"invalid_encrypted_content_retry_attempted",
|
||||
"image_shrink_retry_attempted",
|
||||
|
|
|
|||
169
tests/agent/test_vertex_adapter.py
Normal file
169
tests/agent/test_vertex_adapter.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Tests for the Vertex AI adapter (agent/vertex_adapter.py).
|
||||
|
||||
Vertex uses OAuth2 (short-lived access tokens from a service-account JSON or
|
||||
ADC), NOT a static API key. These tests mock google-auth entirely — no network
|
||||
calls — and cover token minting, the config.yaml→env precedence bridge, the
|
||||
global vs regional base-URL shapes, and the ADC→service-account fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _install_fake_google_auth(monkeypatch, *, adc_ok=True, adc_project="adc-project",
|
||||
sa_project="sa-project", token="ya29.FAKE"):
|
||||
"""Register a fake google-auth tree in sys.modules and return the module set."""
|
||||
ga = types.ModuleType("google.auth")
|
||||
gt = types.ModuleType("google.auth.transport")
|
||||
gtr = types.ModuleType("google.auth.transport.requests")
|
||||
go = types.ModuleType("google.oauth2")
|
||||
gsa = types.ModuleType("google.oauth2.service_account")
|
||||
gp = types.ModuleType("google")
|
||||
|
||||
gtr.Request = type("Request", (), {})
|
||||
|
||||
class _Creds:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
self.expiry = None
|
||||
self.expired = False
|
||||
|
||||
def refresh(self, req):
|
||||
self.token = token
|
||||
|
||||
def _default(scopes=None):
|
||||
if not adc_ok:
|
||||
raise RuntimeError("Could not automatically determine credentials")
|
||||
return _Creds(), adc_project
|
||||
|
||||
ga.default = _default
|
||||
ga.transport = gt
|
||||
gt.requests = gtr
|
||||
|
||||
class _SA:
|
||||
@staticmethod
|
||||
def from_service_account_file(path, scopes=None):
|
||||
c = _Creds()
|
||||
c.project_id = sa_project
|
||||
return c
|
||||
|
||||
gsa.Credentials = _SA
|
||||
go.service_account = gsa
|
||||
gp.auth = ga
|
||||
gp.oauth2 = go
|
||||
|
||||
for name, mod in [
|
||||
("google", gp), ("google.auth", ga), ("google.auth.transport", gt),
|
||||
("google.auth.transport.requests", gtr), ("google.oauth2", go),
|
||||
("google.oauth2.service_account", gsa),
|
||||
]:
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
return gp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vertex_adapter(monkeypatch):
|
||||
"""Fresh vertex_adapter with a fake google-auth and clean caches/env."""
|
||||
for var in ("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"VERTEX_PROJECT_ID", "VERTEX_REGION", "GOOGLE_CLOUD_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
_install_fake_google_auth(monkeypatch)
|
||||
import agent.vertex_adapter as va
|
||||
va = importlib.reload(va)
|
||||
va._creds_cache.clear()
|
||||
# Neutralize config.yaml by default; individual tests re-patch _vertex_config.
|
||||
monkeypatch.setattr(va, "_vertex_config", lambda: {})
|
||||
return va
|
||||
|
||||
|
||||
def test_build_base_url_global(vertex_adapter):
|
||||
url = vertex_adapter.build_vertex_base_url("proj", "global")
|
||||
assert url == (
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/proj/"
|
||||
"locations/global/endpoints/openapi"
|
||||
)
|
||||
|
||||
|
||||
def test_build_base_url_regional(vertex_adapter):
|
||||
url = vertex_adapter.build_vertex_base_url("proj", "us-central1")
|
||||
assert url == (
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/projects/proj/"
|
||||
"locations/us-central1/endpoints/openapi"
|
||||
)
|
||||
|
||||
|
||||
def test_get_vertex_config_uses_adc_and_default_region(vertex_adapter):
|
||||
token, base = vertex_adapter.get_vertex_config()
|
||||
assert token == "ya29.FAKE"
|
||||
assert base == (
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/adc-project/"
|
||||
"locations/global/endpoints/openapi"
|
||||
)
|
||||
|
||||
|
||||
def test_config_yaml_supplies_project_and_region(vertex_adapter, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vertex_adapter, "_vertex_config",
|
||||
lambda: {"project_id": "cfg-project", "region": "europe-west4"},
|
||||
)
|
||||
token, base = vertex_adapter.get_vertex_config()
|
||||
assert token == "ya29.FAKE"
|
||||
assert "projects/cfg-project" in base
|
||||
assert "europe-west4-aiplatform.googleapis.com" in base
|
||||
assert "locations/europe-west4" in base
|
||||
|
||||
|
||||
def test_env_overrides_config_yaml(vertex_adapter, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vertex_adapter, "_vertex_config",
|
||||
lambda: {"project_id": "cfg-project", "region": "cfg-region"},
|
||||
)
|
||||
monkeypatch.setenv("VERTEX_PROJECT_ID", "env-project")
|
||||
monkeypatch.setenv("VERTEX_REGION", "us-east4")
|
||||
assert vertex_adapter._resolve_project_override() == "env-project"
|
||||
assert vertex_adapter._resolve_region() == "us-east4"
|
||||
|
||||
|
||||
def test_has_vertex_credentials_via_config_project(vertex_adapter, monkeypatch):
|
||||
monkeypatch.setattr(vertex_adapter, "_vertex_config", lambda: {"project_id": "p"})
|
||||
assert vertex_adapter.has_vertex_credentials() is True
|
||||
|
||||
|
||||
def test_has_vertex_credentials_false_when_nothing_set(vertex_adapter):
|
||||
assert vertex_adapter.has_vertex_credentials() is False
|
||||
|
||||
|
||||
def test_missing_google_auth_returns_none(monkeypatch):
|
||||
for var in ("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"VERTEX_PROJECT_ID", "VERTEX_REGION"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
import agent.vertex_adapter as va
|
||||
va = importlib.reload(va)
|
||||
monkeypatch.setattr(va, "google", None)
|
||||
va._creds_cache.clear()
|
||||
assert va.get_vertex_credentials() == (None, None)
|
||||
|
||||
|
||||
def test_adc_failure_falls_back_to_service_account(monkeypatch, tmp_path):
|
||||
"""When ADC refresh fails but a service-account JSON exists, use the SA."""
|
||||
for var in ("VERTEX_PROJECT_ID", "VERTEX_REGION", "GOOGLE_CLOUD_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
sa_file = tmp_path / "sa.json"
|
||||
sa_file.write_text('{"project_id": "sa-project"}')
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(sa_file))
|
||||
monkeypatch.delenv("VERTEX_CREDENTIALS_PATH", raising=False)
|
||||
_install_fake_google_auth(monkeypatch, adc_ok=False)
|
||||
import agent.vertex_adapter as va
|
||||
va = importlib.reload(va)
|
||||
va._creds_cache.clear()
|
||||
monkeypatch.setattr(va, "_vertex_config", lambda: {})
|
||||
# A resolvable SA path means the primary cache key is the file (not __adc__),
|
||||
# so this exercises the direct-SA path.
|
||||
token, project = va.get_vertex_credentials()
|
||||
assert token == "ya29.FAKE"
|
||||
assert project == "sa-project"
|
||||
100
tests/hermes_cli/test_vertex_provider.py
Normal file
100
tests/hermes_cli/test_vertex_provider.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Tests for Vertex AI runtime-provider resolution and profile registration.
|
||||
|
||||
Covers: provider-profile registration + aliases, alias canonicalization,
|
||||
resolve_runtime_provider(vertex) minting an OAuth token, and the friendly
|
||||
AuthError when credentials can't be resolved. No network calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_vertex_profile_registered():
|
||||
from providers import get_provider_profile
|
||||
|
||||
p = get_provider_profile("vertex")
|
||||
assert p is not None
|
||||
assert p.name == "vertex"
|
||||
assert p.api_mode == "chat_completions"
|
||||
assert p.auth_type == "vertex"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alias", ["google-vertex", "vertex-ai", "gcp-vertex"])
|
||||
def test_vertex_aliases_resolve(alias):
|
||||
from providers import get_provider_profile
|
||||
|
||||
assert get_provider_profile(alias).name == "vertex"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alias", ["google-vertex", "vertex-ai", "gcp-vertex", "vertexai"])
|
||||
def test_alias_canonicalizes_to_vertex(alias):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
|
||||
assert _PROVIDER_ALIASES[alias] == "vertex"
|
||||
|
||||
|
||||
def test_google_vertex_not_confused_with_gemini():
|
||||
"""`google-vertex` must map to vertex, not the AI-Studio `gemini` provider."""
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
|
||||
assert _PROVIDER_ALIASES["google-vertex"] == "vertex"
|
||||
assert _PROVIDER_ALIASES["google-gemini"] == "gemini"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_mints_token(monkeypatch):
|
||||
import agent.vertex_adapter as va
|
||||
from hermes_cli import runtime_provider as rp
|
||||
|
||||
monkeypatch.setattr(
|
||||
va, "get_vertex_config",
|
||||
lambda: ("ya29.TOKEN", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"),
|
||||
)
|
||||
rt = rp.resolve_runtime_provider(requested="vertex")
|
||||
assert rt["provider"] == "vertex"
|
||||
assert rt["api_mode"] == "chat_completions"
|
||||
assert rt["source"] == "vertex-oauth"
|
||||
assert rt["api_key"] == "ya29.TOKEN"
|
||||
assert "aiplatform.googleapis.com" in rt["base_url"]
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_alias(monkeypatch):
|
||||
import agent.vertex_adapter as va
|
||||
from hermes_cli import runtime_provider as rp
|
||||
|
||||
monkeypatch.setattr(va, "get_vertex_config", lambda: ("t", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"))
|
||||
rt = rp.resolve_runtime_provider(requested="google-vertex")
|
||||
assert rt["provider"] == "vertex"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_raises_autherror_when_unresolved(monkeypatch):
|
||||
import agent.vertex_adapter as va
|
||||
from hermes_cli import runtime_provider as rp
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
monkeypatch.setattr(va, "get_vertex_config", lambda: (None, None))
|
||||
with pytest.raises(AuthError) as exc:
|
||||
rp.resolve_runtime_provider(requested="vertex")
|
||||
msg = str(exc.value)
|
||||
assert "OAuth2" in msg
|
||||
assert "not a static API key" in msg
|
||||
|
||||
|
||||
def test_vertex_extra_body_thinking_config():
|
||||
from providers import get_provider_profile
|
||||
|
||||
p = get_provider_profile("vertex")
|
||||
body = p.build_extra_body(
|
||||
model="google/gemini-3-pro-preview",
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert "extra_body" in body
|
||||
assert "google" in body["extra_body"]
|
||||
assert "thinking_config" in body["extra_body"]["google"]
|
||||
|
||||
|
||||
def test_vertex_extra_body_empty_without_reasoning():
|
||||
from providers import get_provider_profile
|
||||
|
||||
p = get_provider_profile("vertex")
|
||||
assert p.build_extra_body(model="google/gemini-3-flash-preview") == {}
|
||||
|
|
@ -99,6 +99,10 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
|||
"provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452
|
||||
# AWS Bedrock provider
|
||||
"provider.bedrock": ("boto3==1.42.89",),
|
||||
# Google Vertex AI provider — OAuth2 token minting for the Gemini
|
||||
# OpenAI-compatible endpoint. Only loaded when provider=vertex is selected;
|
||||
# google-auth is NOT in [all] so plain installs don't carry it.
|
||||
"provider.vertex": ("google-auth==2.55.1",),
|
||||
# Microsoft Foundry — Entra ID auth (managed identity, workload identity,
|
||||
# service principal, az login, VS Code, azd, PowerShell). Only loaded
|
||||
# when model.auth_mode=entra_id is selected; key-based azure-foundry
|
||||
|
|
|
|||
12
uv.lock
generated
12
uv.lock
generated
|
|
@ -1357,15 +1357,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.49.2"
|
||||
version = "2.55.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1-modules" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1709,6 +1709,9 @@ termux-all = [
|
|||
tts-premium = [
|
||||
{ name = "elevenlabs" },
|
||||
]
|
||||
vertex = [
|
||||
{ name = "google-auth" },
|
||||
]
|
||||
voice = [
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "numpy" },
|
||||
|
|
@ -1763,6 +1766,7 @@ requires-dist = [
|
|||
{ name = "fire", specifier = "==0.7.1" },
|
||||
{ name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" },
|
||||
{ name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" },
|
||||
{ name = "google-auth", marker = "extra == 'vertex'", specifier = "==2.55.1" },
|
||||
{ name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" },
|
||||
{ name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" },
|
||||
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" },
|
||||
|
|
@ -1849,7 +1853,7 @@ requires-dist = [
|
|||
{ name = "websockets", specifier = "==15.0.1" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
|
||||
]
|
||||
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
|
||||
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "hf-xet"
|
||||
|
|
|
|||
146
website/docs/guides/google-vertex.md
Normal file
146
website/docs/guides/google-vertex.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
---
|
||||
sidebar_position: 15
|
||||
title: "Google Vertex AI"
|
||||
description: "Use Hermes Agent with Gemini on Google Cloud Vertex AI — OAuth2 service account or ADC, GCP billing and quotas, no static API key"
|
||||
---
|
||||
|
||||
# Google Vertex AI
|
||||
|
||||
Hermes Agent supports **Gemini models on Google Cloud Vertex AI** through Vertex's OpenAI-compatible endpoint. Unlike the [Google AI Studio provider](/guides/google-gemini) (which uses a static API key against `generativelanguage.googleapis.com`), Vertex gives you **enterprise-grade rate limits and GCP billing/credits**, and is the right choice when you want Gemini usage to draw on your Google Cloud account rather than an AI Studio key.
|
||||
|
||||
:::info Vertex authenticates with OAuth2, not an API key
|
||||
Vertex has **no static API key** for the standard endpoint. Every request needs a short-lived **OAuth2 access token** (≈1 hour TTL) minted from either a service-account JSON or Application Default Credentials (ADC). Hermes mints and **auto-refreshes** these tokens for you — you never paste a token by hand. This is why pasting a temporary token into a custom provider's `api_key` field does not work: it expires mid-session.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **A Google Cloud project** with the **Vertex AI API enabled** and billing active.
|
||||
- **Credentials**, one of:
|
||||
- a **service-account JSON** key file with the `roles/aiplatform.user` role, or
|
||||
- **Application Default Credentials** via `gcloud auth application-default login` (or the metadata server when running on a GCP VM).
|
||||
- **`google-auth`** — installed automatically the first time you select Vertex (lazy install), or explicitly with `pip install 'hermes-agent[vertex]'`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Option A — service account JSON (recommended for servers / gateways)
|
||||
echo "VERTEX_CREDENTIALS_PATH=/path/to/service-account.json" >> ~/.hermes/.env
|
||||
|
||||
# Option B — Application Default Credentials (good for local dev)
|
||||
gcloud auth application-default login
|
||||
|
||||
# Select Vertex as your provider
|
||||
hermes model
|
||||
# → Choose "More providers..." → "Google Vertex AI"
|
||||
# → Enter your GCP project ID (or leave blank to use the one in your credentials)
|
||||
# → Choose a region (default: global)
|
||||
# → Select a Gemini model
|
||||
|
||||
# Start chatting
|
||||
hermes chat
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Vertex splits its settings by sensitivity:
|
||||
|
||||
- The **credential path** is a pointer to a secret and lives in `~/.hermes/.env`.
|
||||
- **Project ID and region** are non-secret routing settings and live in `~/.hermes/config.yaml`.
|
||||
|
||||
`~/.hermes/.env`:
|
||||
|
||||
```bash
|
||||
# One of these (checked in this order); omit both to use ADC:
|
||||
VERTEX_CREDENTIALS_PATH=/path/to/service-account.json
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||
```
|
||||
|
||||
`~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
model:
|
||||
default: google/gemini-3-flash-preview
|
||||
provider: vertex
|
||||
|
||||
vertex:
|
||||
project_id: my-gcp-project # blank → use the project embedded in the credentials
|
||||
region: global # "global" is required for the Gemini 3.x previews
|
||||
```
|
||||
|
||||
:::tip Environment variables win over config.yaml
|
||||
`VERTEX_PROJECT_ID` and `VERTEX_REGION` override the `vertex.project_id` / `vertex.region` values in `config.yaml`. Use them for per-shell overrides; keep the durable settings in `config.yaml`.
|
||||
:::
|
||||
|
||||
### How authentication works
|
||||
|
||||
1. Hermes resolves credentials in this order: `VERTEX_CREDENTIALS_PATH` → `GOOGLE_APPLICATION_CREDENTIALS` → ADC.
|
||||
2. It mints an OAuth2 access token (`cloud-platform` scope) and caches it, refreshing when the token is within 5 minutes of expiry.
|
||||
3. The token is handed to a standard OpenAI client pointed at the Vertex endpoint:
|
||||
```text
|
||||
https://aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{region}/endpoints/openapi
|
||||
```
|
||||
Regional locations use a `{region}-aiplatform.googleapis.com` host instead.
|
||||
4. If a session runs longer than the token lifetime and a request returns `401`, Hermes re-mints the token and retries automatically. On a long-running gateway, if ADC's refresh token has itself expired, Hermes falls back to the service-account JSON when one is configured.
|
||||
|
||||
## Available Models
|
||||
|
||||
Vertex requires the `google/` vendor prefix on model IDs. The `hermes model` picker offers:
|
||||
|
||||
| Model | ID |
|
||||
|-------|----|
|
||||
| Gemini 3.1 Pro Preview | `google/gemini-3.1-pro-preview` |
|
||||
| Gemini 3 Pro Preview | `google/gemini-3-pro-preview` |
|
||||
| Gemini 3 Flash Preview | `google/gemini-3-flash-preview` |
|
||||
| Gemini 3.1 Flash Lite Preview | `google/gemini-3.1-flash-lite-preview` |
|
||||
| Gemini 2.5 Pro | `google/gemini-2.5-pro` |
|
||||
| Gemini 2.5 Flash | `google/gemini-2.5-flash` |
|
||||
|
||||
:::note `global` region for Gemini 3.x
|
||||
The Gemini 3.x preview models are served through the `global` endpoint. Regional endpoints (`us-central1`, etc.) may 404 them. Leave `region: global` unless you have a specific reason to pin a region.
|
||||
:::
|
||||
|
||||
## Switching Models Mid-Session
|
||||
|
||||
```text
|
||||
/model google/gemini-3-pro-preview
|
||||
/model google/gemini-3-flash-preview
|
||||
```
|
||||
|
||||
`/model` switches among already-configured providers and models; it does not collect new credentials. Configure Vertex with `hermes model` first.
|
||||
|
||||
## Reasoning / Thinking
|
||||
|
||||
Vertex exposes Gemini's thinking budget through the OpenAI-compatible surface. Hermes maps its reasoning-effort setting onto `extra_body.google.thinking_config` automatically, so `reasoning_effort` works the same way it does on other Gemini surfaces.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
```bash
|
||||
hermes doctor
|
||||
```
|
||||
|
||||
The doctor reports whether Vertex credentials can be resolved (service-account path or ADC) and whether the provider is configured.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Vertex AI credentials could not be resolved"
|
||||
|
||||
Hermes found neither a service-account JSON nor working ADC. Either set `VERTEX_CREDENTIALS_PATH` in `~/.hermes/.env`, or run `gcloud auth application-default login`. If your project isn't embedded in the credentials, set `vertex.project_id` in `config.yaml`.
|
||||
|
||||
### `google-auth` not installed
|
||||
|
||||
Install the extra: `pip install 'hermes-agent[vertex]'`. Hermes also lazy-installs it the first time you select the Vertex provider.
|
||||
|
||||
### 404 on Gemini 3.x models
|
||||
|
||||
You are probably on a regional endpoint. Set `region: global` in the `vertex:` section of `config.yaml` (or unset `VERTEX_REGION`).
|
||||
|
||||
### 403 / permission denied
|
||||
|
||||
The service account (or your ADC identity) needs the `roles/aiplatform.user` role on the project, and the Vertex AI API must be enabled for that project.
|
||||
|
||||
## Related
|
||||
|
||||
- [Google Gemini (AI Studio)](/guides/google-gemini) — static-API-key Gemini without GCP
|
||||
- [AWS Bedrock](/guides/aws-bedrock) — another native cloud-provider integration
|
||||
- [AI Providers](/integrations/providers)
|
||||
- [Configuration](/user-guide/configuration)
|
||||
|
|
@ -40,6 +40,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
|||
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
|
||||
| **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) |
|
||||
| **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) |
|
||||
| **Google Vertex AI** | `hermes model` → "Google Vertex AI" (provider: `vertex`; OAuth2 via service-account JSON or ADC, GCP billing) |
|
||||
| **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) |
|
||||
| **Azure AI Foundry** | `hermes model` → "Azure AI Foundry" (provider: `azure-foundry`; uses Azure OpenAI / Foundry endpoint and key) |
|
||||
| **AWS Bedrock** | `hermes model` → "AWS Bedrock" (provider: `bedrock`; standard AWS credentials chain via boto3) |
|
||||
|
|
@ -372,6 +373,31 @@ Bedrock uses the **Converse API** under the hood — requests are translated to
|
|||
|
||||
See the [AWS Bedrock guide](/guides/aws-bedrock) for a walkthrough of IAM setup, region selection, and cross-region inference.
|
||||
|
||||
### Google Vertex AI
|
||||
|
||||
Gemini models on Google Cloud Vertex AI via Vertex's OpenAI-compatible endpoint. Authentication is **OAuth2** — a short-lived access token (~1 hour) minted from a service-account JSON or Application Default Credentials (ADC). There is **no static API key**; Hermes mints and auto-refreshes the token for you, including re-minting on a mid-session `401`.
|
||||
|
||||
```bash
|
||||
# Service account JSON (recommended for servers / gateways)
|
||||
echo "VERTEX_CREDENTIALS_PATH=/path/to/service-account.json" >> ~/.hermes/.env
|
||||
# or Application Default Credentials
|
||||
gcloud auth application-default login
|
||||
|
||||
hermes model # → "Google Vertex AI" → project → region → model
|
||||
```
|
||||
|
||||
Or in `config.yaml` (project/region are non-secret and live here; the credential path stays in `.env`):
|
||||
```yaml
|
||||
model:
|
||||
provider: "vertex"
|
||||
default: "google/gemini-3-flash-preview" # Vertex requires the google/ prefix
|
||||
vertex:
|
||||
project_id: "my-gcp-project" # blank → use the project embedded in the credentials
|
||||
region: "global" # required for the Gemini 3.x previews
|
||||
```
|
||||
|
||||
`VERTEX_PROJECT_ID` / `VERTEX_REGION` env vars override the `config.yaml` values. Install with `pip install 'hermes-agent[vertex]'` (or let Hermes lazy-install `google-auth` on first use). See the [Google Vertex AI guide](/guides/google-vertex) for the full walkthrough, and the [Google Gemini guide](/guides/google-gemini) for the static-API-key AI Studio path instead.
|
||||
|
||||
### Qwen Portal (OAuth)
|
||||
|
||||
Alibaba's Qwen Portal with browser-based OAuth login. Pick **Qwen OAuth (Portal)** in `hermes model`, sign in through the browser, and Hermes persists the refresh token.
|
||||
|
|
|
|||
|
|
@ -701,6 +701,7 @@ const sidebars: SidebarsConfig = {
|
|||
'guides/webhook-github-pr-review',
|
||||
'guides/migrate-from-openclaw',
|
||||
'guides/aws-bedrock',
|
||||
'guides/google-vertex',
|
||||
'guides/azure-foundry',
|
||||
'guides/xai-grok-oauth',
|
||||
'guides/oauth-over-ssh',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue