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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1873,11 +1873,10 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
agent._client_log_context(),
)
return client
if agent.provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url

base_url = str(client_kwargs.get("base_url", "") or "")
if is_native_gemini_base_url(base_url):
if is_native_gemini_base_url(client_kwargs.get("base_url", "")):
base_url = str(client_kwargs.get("base_url", "") or "")
safe_kwargs = {
k: v for k, v in client_kwargs.items()
if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"}
Expand Down Expand Up @@ -1940,7 +1939,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
return client


def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mode=''):
def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mode='', auth_header=''):
"""Switch the model/provider in-place for a live agent.

Called by the /model command handlers (CLI and gateway) after
Expand Down Expand Up @@ -2140,6 +2139,12 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"api_key": effective_key,
"base_url": effective_base,
}
# Vertex API key (Express Mode) uses x-goog-api-key header instead
# of Authorization: Bearer *** only works for OAuth2 tokens).
if auth_header == "x-goog-api-key":
agent._client_kwargs["default_headers"] = {
"x-goog-api-key": effective_key,
}
try:
from hermes_cli.config import (
apply_custom_provider_tls_to_client_kwargs,
Expand Down
30 changes: 25 additions & 5 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ def _openai_http_client_kwargs(
return {"http_client": client}

def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
# Gemini/Vertex native API uses GeminiNativeClient instead of OpenAI SDK
try:
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url

if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url, **kwargs)
except Exception:
pass
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
# Hermes owns auxiliary retry + provider/model fallback policy (the
# same-provider transient retry in call_llm plus the except-chain
Expand Down Expand Up @@ -5314,17 +5322,29 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
"no GCP credentials found")
return None, None

token, base_url = get_vertex_config()
if not token or not base_url:
from agent.vertex_adapter import (
get_vertex_config,
has_vertex_api_key,
)

token_or_key, base_url, auth_header = get_vertex_config()
if not token_or_key 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"
default_model = _get_aux_model_for_provider(provider) or "gemini-3.5-flash"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
from openai import OpenAI
client = OpenAI(api_key=token, base_url=base_url)
# Route through _create_openai_client which auto-detects native
# Vertex URLs and creates a GeminiNativeClient (which handles
# x-goog-api-key auth and the native generateContent format).
client = _create_openai_client(
api_key=token_or_key,
base_url=base_url,
**({"default_headers": {"x-goog-api-key": token_or_key}}
if auth_header == "x-goog-api-key" else {}),
)
except Exception as exc:
logger.warning("resolve_provider_client: cannot create Vertex "
"client: %s", exc)
Expand Down
8 changes: 6 additions & 2 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ def bare_gemini_model_id(model: str) -> str:


def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
"""Return True when the endpoint speaks Gemini's native REST API.

Accepts both Google AI Studio (generativelanguage.googleapis.com) and
Vertex AI (aiplatform.googleapis.com) native endpoints.
"""
normalized = str(base_url or "").strip().rstrip("/").lower()
if not normalized:
return False
if "generativelanguage.googleapis.com" not in normalized:
if "generativelanguage.googleapis.com" not in normalized and "aiplatform.googleapis.com" not in normalized:
return False
return not normalized.endswith("/openai")

Expand Down
203 changes: 185 additions & 18 deletions agent/vertex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,43 @@
endpoint. This allows Hermes to use Gemini models via Google Cloud with
enterprise-grade rate limits and quotas.

Requires: pip install google-auth
Supports two authentication methods:

Environment variables honored (all optional):
1. **API Key (Express Mode) — RECOMMENDED.**
Set ``GOOGLE_VERTEX_API_KEY`` in your .env file. This is a static key that
works with Vertex's Express Mode endpoint. No OAuth, no service-account
JSON, no ADC setup. You also need:

- ``GOOGLE_VERTEX_PROJECT`` — your GCP project ID.
- ``GOOGLE_VERTEX_LOCATION`` — region (default: ``us-central1``).

When using API key auth the ``Authorization`` header carries the key as a
Bearer token and the request is routed to the global ``aiplatform.googleapis.com``
host, which internally fans out to the configured region.

2. **OAuth2 / ADC (legacy)**
Requires ``pip install google-auth`` and one of:

- ``GOOGLE_APPLICATION_CREDENTIALS`` — path to a service account JSON.
- ``VERTEX_CREDENTIALS_PATH`` — alias, takes precedence if set.
- ``gcloud auth application-default login`` — local ADC.

Additional routing settings (non-secret) live in ``config.yaml`` under
the ``vertex:`` section — project_id and region.

Auth selection is automatic: if ``GOOGLE_VERTEX_API_KEY`` is set, the API key
path is used. Otherwise the adapter falls back to OAuth2 / ADC.

API key env vars (all optional):
GOOGLE_VERTEX_API_KEY — Vertex AI API key for Express Mode (secret).
GOOGLE_VERTEX_PROJECT — GCP project ID (secret — read at runtime).
GOOGLE_VERTEX_LOCATION — Vertex region (default: us-central1).

OAuth2 / ADC env vars (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.
VERTEX_REGION — override default region ("us-central1").
"""

import logging
Expand Down Expand Up @@ -42,7 +69,15 @@

logger = logging.getLogger(__name__)

DEFAULT_REGION = "global"
# Environment variable constants for API key auth (Express Mode)
GOOGLE_VERTEX_API_KEY = "GOOGLE_VERTEX_API_KEY"
GOOGLE_VERTEX_PROJECT = "GOOGLE_VERTEX_PROJECT"
GOOGLE_VERTEX_LOCATION = "GOOGLE_VERTEX_LOCATION"

# Default region — us-central1 is the most widely available Vertex region.
# The old default was "global" (required for Gemini 3.x previews via ADC),
# but API key / Express Mode works best with an explicit region.
DEFAULT_REGION = "us-central1"

_creds_cache: dict = {}

Expand All @@ -64,9 +99,13 @@ def _vertex_config() -> dict:


def _resolve_region(explicit: Optional[str] = None) -> str:
"""Region precedence: explicit arg > VERTEX_REGION env > config.yaml > default."""
"""Region precedence: explicit arg > GOOGLE_VERTEX_LOCATION env > VERTEX_REGION env > config.yaml > default."""
if explicit:
return explicit
# Check GOOGLE_VERTEX_LOCATION first (API key / Express Mode preferred env var)
gv_location = (_get_secret(GOOGLE_VERTEX_LOCATION) or "").strip()
if gv_location:
return gv_location
env_region = (_get_secret("VERTEX_REGION") or "").strip()
if env_region:
return env_region
Expand All @@ -75,11 +114,15 @@ def _resolve_region(explicit: Optional[str] = None) -> str:


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

Returns None when neither is set (the credentials' embedded project_id
is used in that case).
is used in that case for OAuth2; for API key mode the caller should
prompt for a project if this returns None).
"""
gv_project = (_get_secret(GOOGLE_VERTEX_PROJECT) or "").strip()
if gv_project:
return gv_project
env_project = (_get_secret("VERTEX_PROJECT_ID") or "").strip()
if env_project:
return env_project
Expand All @@ -103,6 +146,31 @@ def _resolve_credentials_path(explicit: Optional[str]) -> Optional[str]:
return None


def has_vertex_api_key() -> bool:
"""Check whether a Vertex AI API key is configured."""
return bool(_get_secret(GOOGLE_VERTEX_API_KEY))


def resolve_vertex_api_key() -> Optional[str]:
"""Return the configured Vertex AI API key, or None."""
return _get_secret(GOOGLE_VERTEX_API_KEY)


def build_vertex_api_key_base_url(project_id: str, region: str) -> str:
"""Build the base URL for Vertex AI Express Mode native API.

Express Mode uses the native ``generateContent`` API (not the
OpenAI-compatible endpoint). The URL points at the global
``aiplatform.googleapis.com`` host with the ``publishers/google``
prefix so that the ``GeminiNativeClient`` constructs the correct
``:generateContent`` endpoint path.

The project and region are embedded in the API key itself — they
are not part of the URL in Express Mode native API calls.
"""
return "https://aiplatform.googleapis.com/v1/publishers/google"


def _refresh_credentials(creds) -> None:
auth_req = google.auth.transport.requests.Request()
creds.refresh(auth_req)
Expand Down Expand Up @@ -202,27 +270,126 @@ def build_vertex_base_url(project_id: str, region: str = DEFAULT_REGION) -> str:
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."""
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve (access_token_or_api_key, base_url, auth_header_type) for Vertex AI.

Two authentication paths, chosen automatically:

1. **API Key (Express Mode)** — if ``GOOGLE_VERTEX_API_KEY`` is set.
Returns (api_key, base_url_with_project, ``"x-goog-api-key"``).
No ``google-auth`` needed. The caller must set the returned header
name (not ``Authorization: Bearer``) on each request.

2. **OAuth2 / ADC** — legacy path. Returns (oauth2_token, base_url, ``"Authorization"``).
Requires ``google-auth`` and valid GCP credentials.

Returns (None, None, None) when no credentials can be resolved.
"""
# --- Path 1: API Key (Express Mode) ---
api_key = resolve_vertex_api_key()
if api_key:
project_id = _resolve_project_override()
if not project_id:
logger.warning(
"Vertex API key found but no project ID configured. "
"Set GOOGLE_VERTEX_PROJECT in ~/.hermes/.env."
)
return None, None, None
effective_region = _resolve_region(region)
base_url = build_vertex_api_key_base_url(project_id, effective_region)
logger.debug(
"get_vertex_config: using API key (Express Mode) for project %s in %s",
project_id, effective_region,
)
return api_key, base_url, "x-goog-api-key"

# --- Path 2: OAuth2 / ADC (legacy) ---
token, project_id = get_vertex_credentials(credentials_path)
if not token or not project_id:
return None, None
return None, None, None

effective_region = _resolve_region(region)
base_url = build_vertex_base_url(project_id, effective_region)
return token, base_url
return token, base_url, "Authorization"


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).
Returns True when either:
- A Vertex API key (GOOGLE_VERTEX_API_KEY) is set, OR
- A service account JSON path is resolvable, OR
- An explicit project ID is configured (ADC intended).

No network calls and no ``google-auth`` import — safe for provider
auto-detection and setup-status display.
"""
if has_vertex_api_key():
return True
if _resolve_credentials_path(None):
return True
if _resolve_project_override():
return True
return False


# ── Model Discovery ──────────────────────────────────────────────────────────


def discover_vertex_models(
api_key: str,
project_id: str,
region: str = DEFAULT_REGION,
timeout: float = 10.0,
) -> list[str]:
"""Query Vertex AI's ``models.list`` publisher endpoint for models
available in the given project and region.

**Note:** The ``publishers/google/models`` endpoint is only accessible
via OAuth2 / ADC authentication. When using an API key (Express Mode),
this endpoint returns 404. The function will return an empty list with
API key auth, and the caller should fall back to a curated model list.

For OAuth2 / ADC auth, returns a sorted list of model ID strings
(e.g. ``gemini-2.5-flash``, ``gemini-3-flash-preview``). Only models
that support ``generateContent`` (chat / text-generation) are returned.

Returns the sorted model list on success.
Returns an empty list on any error (network, auth, parse, or API key auth).
"""
import json
import urllib.error
import urllib.request

host = "aiplatform.googleapis.com" if region == "global" else f"{region}-aiplatform.googleapis.com"
url = (
f"https://{host}/v1/projects/{project_id}/locations/{region}"
"/publishers/google/models"
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}

try:
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())

models: list[str] = []
for entry in data.get("models", []):
methods = entry.get("supportedGenerationMethods", [])
if "generateContent" in methods:
# The ``name`` field is a full resource path:
# projects/{project}/locations/{region}/publishers/google/models/{model_id}
model_id = entry["name"].rsplit("/", 1)[-1]
models.append(model_id)

return sorted(set(models))

except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as exc:
logger.warning("discover_vertex_models: failed to list models — %s", exc)
return []
except Exception as exc:
logger.warning("discover_vertex_models: unexpected error — %s", exc)
return []
7 changes: 7 additions & 0 deletions apps/desktop/src/app/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [
docsUrl: 'https://aistudio.google.com/app/apikey',
priority: 4
},
{
prefix: 'GOOGLE_VERTEX_',
name: 'Google Vertex AI',
description: 'Vertex AI (Gemini via GCP; API key or OAuth2/ADC)',
docsUrl: 'https://console.cloud.google.com/apis/credentials',
priority: 4
},
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
Expand Down
Loading