From c73e74386b20164fa218414131fe8b03f07b3e7c Mon Sep 17 00:00:00 2001 From: Steve Lawton Date: Wed, 1 Jul 2026 04:42:07 -0700 Subject: [PATCH] feat(vertex): add Google Vertex AI provider for Gemini (OAuth2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/auxiliary_client.py | 36 ++++ agent/conversation_loop.py | 10 + agent/turn_retry_state.py | 1 + agent/usage_pricing.py | 5 + agent/vertex_adapter.py | 202 +++++++++++++++++++++ hermes_cli/config.py | 30 +++ hermes_cli/main.py | 5 +- hermes_cli/model_setup_flows.py | 104 +++++++++++ hermes_cli/models.py | 7 +- hermes_cli/runtime_provider.py | 33 ++++ hermes_cli/setup.py | 5 + plugins/model-providers/vertex/__init__.py | 75 ++++++++ plugins/model-providers/vertex/plugin.yaml | 5 + pyproject.toml | 1 + run_agent.py | 42 ++++- scripts/release.py | 1 + tests/agent/test_turn_retry_state.py | 1 + tests/agent/test_vertex_adapter.py | 169 +++++++++++++++++ tests/hermes_cli/test_vertex_provider.py | 100 ++++++++++ tools/lazy_deps.py | 4 + uv.lock | 12 +- website/docs/guides/google-vertex.md | 146 +++++++++++++++ website/docs/integrations/providers.md | 26 +++ website/sidebars.ts | 1 + 24 files changed, 1014 insertions(+), 7 deletions(-) create mode 100644 agent/vertex_adapter.py create mode 100644 plugins/model-providers/vertex/__init__.py create mode 100644 plugins/model-providers/vertex/plugin.yaml create mode 100644 tests/agent/test_vertex_adapter.py create mode 100644 tests/hermes_cli/test_vertex_provider.py create mode 100644 website/docs/guides/google-vertex.md diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3ce0308cea7..ca42734d0eb 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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). diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 17378a9d1c4..5d3dfb572ef 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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" diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 2298e14c24c..3d231fef9ff 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -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 diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 15ec79f4e50..32338caff80 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -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") diff --git a/agent/vertex_adapter.py b/agent/vertex_adapter.py new file mode 100644 index 00000000000..685dff49c55 --- /dev/null +++ b/agent/vertex_adapter.py @@ -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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9257ca6f9c9..4b2b47658c5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b71c59f3835..aedcb603e54 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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", diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index ba42fab485c..46ec15ffb6e 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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//" + f"locations/{region}/endpoints/openapi" + if region == "global" + else f"https://{region}-aiplatform.googleapis.com/v1beta1/projects//" + 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. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index cf3eb40edaa..cdd8f93d699 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 690c2c96102..5e71be4130c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index a178c0b5ca9..3aae9c07f2c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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"], diff --git a/plugins/model-providers/vertex/__init__.py b/plugins/model-providers/vertex/__init__.py new file mode 100644 index 00000000000..f0d0d4f896b --- /dev/null +++ b/plugins/model-providers/vertex/__init__.py @@ -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) diff --git a/plugins/model-providers/vertex/plugin.yaml b/plugins/model-providers/vertex/plugin.yaml new file mode 100644 index 00000000000..eb7479a0b58 --- /dev/null +++ b/plugins/model-providers/vertex/plugin.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 437938f8922..6f3b27790a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/run_agent.py b/run_agent.py index 5587fe648f0..f62229deb11 100644 --- a/run_agent.py +++ b/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 diff --git a/scripts/release.py b/scripts/release.py index 86709381106..4a45311ddde 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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) diff --git a/tests/agent/test_turn_retry_state.py b/tests/agent/test_turn_retry_state.py index 83664eb9800..687a63f4189 100644 --- a/tests/agent/test_turn_retry_state.py +++ b/tests/agent/test_turn_retry_state.py @@ -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", diff --git a/tests/agent/test_vertex_adapter.py b/tests/agent/test_vertex_adapter.py new file mode 100644 index 00000000000..b68cac979a6 --- /dev/null +++ b/tests/agent/test_vertex_adapter.py @@ -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" diff --git a/tests/hermes_cli/test_vertex_provider.py b/tests/hermes_cli/test_vertex_provider.py new file mode 100644 index 00000000000..af67aacac29 --- /dev/null +++ b/tests/hermes_cli/test_vertex_provider.py @@ -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") == {} diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index c69b5374a63..85776ac904d 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -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 diff --git a/uv.lock b/uv.lock index 03af833d3c8..06133cb55bd 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/website/docs/guides/google-vertex.md b/website/docs/guides/google-vertex.md new file mode 100644 index 00000000000..851a391691c --- /dev/null +++ b/website/docs/guides/google-vertex.md @@ -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) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 1378762f346..a49b15d04de 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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. diff --git a/website/sidebars.ts b/website/sidebars.ts index 44f3832f0e2..b012354a532 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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',