feat(gemini): add Google Gemini (OAuth) inference provider

Adds 'google-gemini-cli' as a first-class inference provider using
Authorization Code + PKCE (S256) OAuth against Google's accounts.google.com,
hitting the OpenAI-compatible Gemini endpoint (v1beta/openai) with a Bearer
access token. Users sign in with their Google account — no API-key copy-paste.

Synthesized from three competing PRs per multi-PR design analysis:
- Clean PKCE module structure shaped after #10176 (thanks @sliverp)
- Cross-process file lock (fcntl POSIX / msvcrt Windows) with thread-local
  re-entrancy counter from #10779 (thanks @newarthur)
- Rejects #6745's subprocess approach entirely (different paradigm)

Improvements over the competing PRs:
- Port fallback: if 8085 is taken, bind ephemeral port instead of failing
- Preserves refresh_token when Google omits one (correct per Google spec)
- Accepts both full redirect URL and bare code in paste fallback
- doctor.py health check (neither PR had this)
- No regression in _OAUTH_CAPABLE_PROVIDERS (#10779 dropped anthropic/nous)
- No bundled unrelated features (#10779 mixed in persona/personality routing)

Storage:
- ~/.hermes/auth/google_oauth.json (0o600, atomic write via fsync+replace)
- Cross-process fcntl/msvcrt lock with 30s timeout
- Refresh 5 min before expiry on every request via get_valid_access_token

Provider registration (9-point checklist):
- auth.py: PROVIDER_REGISTRY entry, aliases (gemini-cli, gemini-oauth),
  resolve_gemini_oauth_runtime_credentials, get_gemini_oauth_auth_status,
  get_auth_status() dispatch
- models.py: _PROVIDER_MODELS catalog, CANONICAL_PROVIDERS entry, aliases
- providers.py: HermesOverlay, ALIASES entries
- runtime_provider.py: resolve_runtime_provider() dispatch branch
- config.py: OPTIONAL_ENV_VARS for HERMES_GEMINI_CLIENT_ID/_SECRET/_BASE_URL
- main.py: _model_flow_google_gemini_cli, select_provider_and_model dispatch
- auth_commands.py: add-to-pool handler, _OAUTH_CAPABLE_PROVIDERS
- doctor.py: 'Google Gemini OAuth' status line

Client ID: Not shipped. Users register a Desktop OAuth client in Google Cloud
Console (Generative Language API) and set HERMES_GEMINI_CLIENT_ID in
~/.hermes/.env. Documented in website/docs/integrations/providers.md.

Tests: 44 new unit tests covering PKCE S256 roundtrip, credential I/O
(permissions + atomic write), cross-process lock, port fallback, paste
fallback (URL + bare code), token exchange/refresh, rotation handling,
get_valid_access_token refresh semantics, runtime provider dispatch,
alias resolution, and regression guards for _OAUTH_CAPABLE_PROVIDERS.

Docs: new 'Google Gemini via OAuth' section in providers.md with full
walkthrough including GCP Desktop OAuth client registration, and env var
table updated in environment-variables.md.

Closes partial work in #6745, #10176, #10779 (to be closed with credit
once this merges).
This commit is contained in:
Teknium 2026-04-16 15:08:49 -07:00
parent 387aa9afc9
commit 1e5ee33f68
No known key found for this signature in database
12 changed files with 1693 additions and 4 deletions

797
agent/google_oauth.py Normal file
View file

@ -0,0 +1,797 @@
"""Google OAuth PKCE flow for the Gemini (google-gemini-cli) inference provider.
This module implements the browser-based Authorization Code + PKCE (S256) flow
against Google's OAuth 2.0 endpoints so users can authenticate Hermes with their
Google account and hit the Gemini OpenAI-compatible endpoint
(``https://generativelanguage.googleapis.com/v1beta/openai``) with a Bearer
access token instead of copy-pasting an API key.
Synthesized from competing PRs #10176 (@sliverp) and #10779 (@newarthur):
- PKCE generator, save/load, exchange, refresh, login flow, email fetch shaped
after #10176's clean module layout.
- Cross-process file lock (fcntl on POSIX, msvcrt on Windows) with a thread-local
re-entrancy counter from #10779. This prevents two Hermes processes racing
a token refresh and clobbering the rotated refresh_token.
Flow summary
------------
1. Generate a PKCE verifier/challenge pair (S256).
2. Spin up a localhost HTTP server on 127.0.0.1 to receive the OAuth callback.
If port 8085 is taken, we fall back to an ephemeral port and retry.
3. Open ``accounts.google.com/o/oauth2/v2/auth?...`` in the user's browser.
4. Capture the ``code`` from the callback (or accept a manual paste in headless
environments) and exchange it for access + refresh tokens.
5. Save tokens atomically to ``~/.hermes/auth/google_oauth.json`` (0o600).
6. On every runtime request, ``get_valid_access_token()`` loads the file, refreshes
the access token if it expires within ``REFRESH_SKEW_SECONDS``, and returns
a fresh bearer token.
Client ID / secret
------------------
No OAuth client is shipped by default. A maintainer must register a "Desktop"
OAuth client in Google Cloud Console (Generative Language API enabled) and set
``HERMES_GEMINI_CLIENT_ID`` (and optionally ``HERMES_GEMINI_CLIENT_SECRET``) in
``~/.hermes/.env``. See ``website/docs/integrations/providers.md`` for the full
registration walkthrough.
Storage format (``~/.hermes/auth/google_oauth.json``)::
{
"client_id": "...",
"client_secret": "...",
"access_token": "...",
"refresh_token": "...",
"expires_at": 1744848000.0, // unix seconds, float
"email": "user@example.com" // optional, display-only
}
Public API
----------
- ``start_oauth_flow(force_relogin=False)`` interactive browser login
- ``get_valid_access_token()`` runtime entry, refreshes as needed
- ``load_credentials()`` / ``save_credentials()`` / ``clear_credentials()``
- ``refresh_access_token()`` standalone refresh helper
- ``run_gemini_oauth_login_pure()`` credential-pool-compatible variant
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import http.server
import json
import logging
import os
import secrets
import socket
import stat
import threading
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# =============================================================================
# Constants
# =============================================================================
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v1/userinfo"
# Scopes: generative-language lets us hit v1beta/openai; userinfo.email is for
# the display label in `hermes auth list`. Joined with a space per RFC 6749.
OAUTH_SCOPES = (
"https://www.googleapis.com/auth/generative-language "
"https://www.googleapis.com/auth/userinfo.email"
)
# Preferred loopback port. If busy we ask the OS for an ephemeral port.
# 8085 matches Google's own gemini-cli so users who already authorized a redirect
# URI on that port keep working.
DEFAULT_REDIRECT_PORT = 8085
REDIRECT_HOST = "127.0.0.1"
CALLBACK_PATH = "/oauth2callback"
# Refresh the access token when fewer than this many seconds remain.
REFRESH_SKEW_SECONDS = 300 # 5 minutes
# Default timeouts
TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0
CALLBACK_WAIT_SECONDS = 300 # 5 min for user to complete browser flow
LOCK_TIMEOUT_SECONDS = 30.0
# Environment overrides (documented in reference/environment-variables.md)
ENV_CLIENT_ID = "HERMES_GEMINI_CLIENT_ID"
ENV_CLIENT_SECRET = "HERMES_GEMINI_CLIENT_SECRET"
# Module-level default client credentials. Empty until a maintainer registers
# a "Hermes Agent" desktop OAuth client in Google Cloud Console and pastes the
# values here (or sets them as env vars at runtime). See module docstring.
_DEFAULT_CLIENT_ID = ""
_DEFAULT_CLIENT_SECRET = ""
# =============================================================================
# Error types
# =============================================================================
class GoogleOAuthError(RuntimeError):
"""Raised for any failure in the Google OAuth flow."""
def __init__(self, message: str, *, code: str = "google_oauth_error") -> None:
super().__init__(message)
self.code = code
# =============================================================================
# File paths & cross-process locking
# =============================================================================
def _credentials_path() -> Path:
"""Location of the Gemini OAuth creds file."""
return get_hermes_home() / "auth" / "google_oauth.json"
def _lock_path() -> Path:
return _credentials_path().with_suffix(".json.lock")
# Re-entrancy depth counter so nested calls to _credentials_lock() from the same
# thread don't deadlock. Per-thread via threading.local.
_lock_state = threading.local()
@contextlib.contextmanager
def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):
"""Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows).
Thread-safe via a per-thread re-entrancy counter nested acquisitions by the
same thread no-op after the first. On unsupported platforms this degrades to
a threading.Lock (still safe within a single process).
"""
depth = getattr(_lock_state, "depth", 0)
if depth > 0:
_lock_state.depth = depth + 1
try:
yield
finally:
_lock_state.depth -= 1
return
lock_file_path = _lock_path()
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600)
acquired = False
try:
try:
import fcntl # POSIX
except ImportError:
fcntl = None # Windows
if fcntl is not None:
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}."
)
time.sleep(0.05)
else:
try:
import msvcrt # type: ignore[import-not-found]
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
while True:
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
acquired = True
break
except OSError:
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}."
)
time.sleep(0.05)
except ImportError:
# Last resort: threading-only
logger.debug("Neither fcntl nor msvcrt available; falling back to thread-only lock")
acquired = True
_lock_state.depth = 1
yield
finally:
try:
if acquired:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
except ImportError:
try:
import msvcrt # type: ignore[import-not-found]
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
except ImportError:
pass
finally:
os.close(fd)
_lock_state.depth = 0
# =============================================================================
# Client ID resolution
# =============================================================================
def _get_client_id() -> str:
return (os.getenv(ENV_CLIENT_ID) or _DEFAULT_CLIENT_ID).strip()
def _get_client_secret() -> str:
return (os.getenv(ENV_CLIENT_SECRET) or _DEFAULT_CLIENT_SECRET).strip()
def _require_client_id() -> str:
client_id = _get_client_id()
if not client_id:
raise GoogleOAuthError(
"Google OAuth client ID is not configured. Set "
f"{ENV_CLIENT_ID} in ~/.hermes/.env, or ask the Hermes maintainers to "
"ship a default. See "
"https://github.com/NousResearch/hermes-agent/blob/main/website/docs/integrations/providers.md "
"for the GCP Desktop OAuth client registration walkthrough.",
code="google_oauth_client_id_missing",
)
return client_id
# =============================================================================
# PKCE
# =============================================================================
def _generate_pkce_pair() -> Tuple[str, str]:
"""Generate a PKCE (verifier, challenge) pair using S256.
Verifier is 43128 chars of url-safe base64 (we use 64 bytes 86 chars).
Challenge is SHA256(verifier), url-safe base64 without padding.
"""
verifier = secrets.token_urlsafe(64) # 86 chars, well within PKCE limits
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
# =============================================================================
# Credential I/O
# =============================================================================
@dataclass
class GoogleCredentials:
access_token: str
refresh_token: str
expires_at: float
client_id: str
client_secret: str = ""
email: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"client_id": self.client_id,
"client_secret": self.client_secret,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": float(self.expires_at),
"email": self.email,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GoogleCredentials":
return cls(
access_token=str(data.get("access_token", "") or ""),
refresh_token=str(data.get("refresh_token", "") or ""),
expires_at=float(data.get("expires_at", 0) or 0),
client_id=str(data.get("client_id", "") or ""),
client_secret=str(data.get("client_secret", "") or ""),
email=str(data.get("email", "") or ""),
)
def load_credentials() -> Optional[GoogleCredentials]:
"""Load credentials from disk. Returns None if missing or corrupt."""
path = _credentials_path()
if not path.exists():
return None
try:
with _credentials_lock():
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
except (json.JSONDecodeError, OSError, IOError) as exc:
logger.warning("Failed to read Google OAuth credentials at %s: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("Google OAuth credentials file %s is not a JSON object", path)
return None
creds = GoogleCredentials.from_dict(data)
if not creds.access_token:
return None
return creds
def save_credentials(creds: GoogleCredentials) -> Path:
"""Atomically write creds to disk with 0600 permissions."""
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Serialize outside the lock to minimize hold time
payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n"
with _credentials_lock():
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(tmp_path, path)
finally:
# Cleanup temp file if replace failed partway
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
return path
def clear_credentials() -> None:
"""Remove the creds file and its lock file. Idempotent."""
path = _credentials_path()
with _credentials_lock():
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.warning("Failed to remove Google OAuth credentials at %s: %s", path, exc)
# =============================================================================
# Token endpoint
# =============================================================================
def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:
"""POST x-www-form-urlencoded and return parsed JSON response."""
body = urllib.parse.urlencode(data).encode("ascii")
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
return json.loads(raw)
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", errors="replace")
except Exception:
pass
raise GoogleOAuthError(
f"Google OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}",
code="google_oauth_token_http_error",
) from exc
except urllib.error.URLError as exc:
raise GoogleOAuthError(
f"Google OAuth token request failed: {exc}",
code="google_oauth_token_network_error",
) from exc
def exchange_code(
code: str,
verifier: str,
redirect_uri: str,
*,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
"""Exchange an authorization code for access + refresh tokens."""
cid = client_id if client_id is not None else _require_client_id()
csecret = client_secret if client_secret is not None else _get_client_secret()
data = {
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"client_id": cid,
"redirect_uri": redirect_uri,
}
if csecret:
data["client_secret"] = csecret
return _post_form(TOKEN_ENDPOINT, data, timeout)
def refresh_access_token(
refresh_token: str,
*,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
"""Refresh the access token using a refresh token."""
if not refresh_token:
raise GoogleOAuthError(
"Cannot refresh: refresh_token is empty. Re-run `hermes login --provider google-gemini-cli`.",
code="google_oauth_refresh_token_missing",
)
cid = client_id if client_id is not None else _require_client_id()
csecret = client_secret if client_secret is not None else _get_client_secret()
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": cid,
}
if csecret:
data["client_secret"] = csecret
return _post_form(TOKEN_ENDPOINT, data, timeout)
def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:
"""Best-effort userinfo lookup for display. Failures return empty string."""
try:
request = urllib.request.Request(
USERINFO_ENDPOINT + "?alt=json",
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
data = json.loads(raw)
return str(data.get("email", "") or "")
except Exception as exc:
logger.debug("Userinfo fetch failed (non-fatal): %s", exc)
return ""
# =============================================================================
# Local callback server
# =============================================================================
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""Captures the OAuth callback code/error and returns a styled HTML page."""
# Injected at class level before each flow; protected by the server being
# single-shot (we spin up a new HTTPServer per flow).
expected_state: str = ""
captured_code: Optional[str] = None
captured_error: Optional[str] = None
ready: Optional[threading.Event] = None
def log_message(self, format: str, *args: Any) -> None: # noqa: A002, N802
# Silence default access-log chatter
logger.debug("OAuth callback: " + format, *args)
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
params = urllib.parse.parse_qs(parsed.query)
state = (params.get("state") or [""])[0]
error = (params.get("error") or [""])[0]
code = (params.get("code") or [""])[0]
if state != type(self).expected_state:
type(self).captured_error = "state_mismatch"
self._respond_html(400, _ERROR_PAGE.format(message="State mismatch — aborting for safety."))
elif error:
type(self).captured_error = error
self._respond_html(400, _ERROR_PAGE.format(message=f"Authorization denied: {error}"))
elif code:
type(self).captured_code = code
self._respond_html(200, _SUCCESS_PAGE)
else:
type(self).captured_error = "no_code"
self._respond_html(400, _ERROR_PAGE.format(message="Callback received no authorization code."))
if type(self).ready is not None:
type(self).ready.set()
def _respond_html(self, status: int, body: str) -> None:
payload = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
_SUCCESS_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>Hermes signed in</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }
h1 { color: #1a7f37; } p { color: #555; }
</style></head>
<body><h1>Signed in to Google.</h1>
<p>You can close this tab and return to your terminal.</p></body></html>
"""
_ERROR_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>Hermes sign-in failed</title>
<style>
body {{ font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }}
h1 {{ color: #b42318; }} p {{ color: #555; }}
</style></head>
<body><h1>Sign-in failed</h1><p>{message}</p>
<p>Return to your terminal Hermes can walk you through a manual paste fallback.</p></body></html>
"""
def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]:
"""Try to bind on the preferred port; fall back to an ephemeral port if busy.
Returns (server, actual_port) or raises OSError if even the ephemeral bind fails.
"""
try:
server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler)
return server, preferred_port
except OSError as exc:
logger.info(
"Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port",
preferred_port, exc,
)
# Ephemeral port fallback
server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler)
return server, server.server_address[1]
def _port_is_available(port: int) -> bool:
"""Return True if ``port`` can be bound on the loopback interface."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((REDIRECT_HOST, port))
return True
except OSError:
return False
# =============================================================================
# Main login flow
# =============================================================================
def start_oauth_flow(
*,
force_relogin: bool = False,
open_browser: bool = True,
callback_wait_seconds: float = CALLBACK_WAIT_SECONDS,
) -> GoogleCredentials:
"""Run the interactive browser OAuth flow and persist credentials.
Args:
force_relogin: If False and valid creds already exist, return them unchanged.
open_browser: If False, skip webbrowser.open and print the URL only.
callback_wait_seconds: Max seconds to wait for the browser callback.
Returns:
GoogleCredentials that were just saved to disk.
"""
if not force_relogin:
existing = load_credentials()
if existing and existing.access_token:
logger.info("Google OAuth credentials already present; skipping login.")
return existing
client_id = _require_client_id()
client_secret = _get_client_secret()
verifier, challenge = _generate_pkce_pair()
state = secrets.token_urlsafe(16)
server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT)
redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}"
# Reset class-level capture state on the handler
_OAuthCallbackHandler.expected_state = state
_OAuthCallbackHandler.captured_code = None
_OAuthCallbackHandler.captured_error = None
ready = threading.Event()
_OAuthCallbackHandler.ready = ready
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": OAUTH_SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"access_type": "offline",
# prompt=consent on initial login ensures Google issues a refresh_token.
# We do NOT pass prompt=consent on refresh, so users aren't re-nagged.
"prompt": "consent",
}
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
print()
print("Opening your browser to sign in to Google…")
print(f"If it does not open automatically, visit:\n {auth_url}")
print()
if open_browser:
try:
import webbrowser
webbrowser.open(auth_url, new=1, autoraise=True)
except Exception as exc:
logger.debug("webbrowser.open failed: %s", exc)
code: Optional[str] = None
try:
if ready.wait(timeout=callback_wait_seconds):
code = _OAuthCallbackHandler.captured_code
error = _OAuthCallbackHandler.captured_error
if error:
raise GoogleOAuthError(
f"Authorization failed: {error}",
code="google_oauth_authorization_failed",
)
else:
logger.info("Callback server timed out — offering manual paste fallback.")
code = _prompt_paste_fallback()
finally:
try:
server.shutdown()
except Exception:
pass
try:
server.server_close()
except Exception:
pass
server_thread.join(timeout=2.0)
if not code:
raise GoogleOAuthError(
"No authorization code received. Aborting.",
code="google_oauth_no_code",
)
token_resp = exchange_code(
code, verifier, redirect_uri,
client_id=client_id, client_secret=client_secret,
)
return _persist_token_response(token_resp, client_id=client_id, client_secret=client_secret)
def _prompt_paste_fallback() -> Optional[str]:
"""Ask the user to paste the callback URL or raw code (headless fallback)."""
print()
print("The browser callback did not complete automatically (port blocked or headless env).")
print("Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.")
raw = input("Callback URL or code: ").strip()
if not raw:
return None
if raw.startswith("http://") or raw.startswith("https://"):
parsed = urllib.parse.urlparse(raw)
params = urllib.parse.parse_qs(parsed.query)
return (params.get("code") or [""])[0] or None
return raw
def _persist_token_response(
token_resp: Dict[str, Any],
*,
client_id: str,
client_secret: str,
) -> GoogleCredentials:
access_token = str(token_resp.get("access_token", "") or "").strip()
refresh_token = str(token_resp.get("refresh_token", "") or "").strip()
expires_in = int(token_resp.get("expires_in", 0) or 0)
if not access_token or not refresh_token:
raise GoogleOAuthError(
"Google token response missing access_token or refresh_token.",
code="google_oauth_incomplete_token_response",
)
creds = GoogleCredentials(
access_token=access_token,
refresh_token=refresh_token,
expires_at=time.time() + max(60, expires_in),
client_id=client_id,
client_secret=client_secret,
email=_fetch_user_email(access_token),
)
save_credentials(creds)
logger.info("Google OAuth credentials saved to %s", _credentials_path())
return creds
# =============================================================================
# Runtime token accessor (called on every inference request)
# =============================================================================
def get_valid_access_token(*, force_refresh: bool = False) -> str:
"""Load creds, refreshing if near expiry, and return a valid bearer token.
Raises GoogleOAuthError if no creds are stored or if refresh fails.
"""
creds = load_credentials()
if creds is None:
raise GoogleOAuthError(
"No Google OAuth credentials found. Run `hermes login --provider google-gemini-cli` first.",
code="google_oauth_not_logged_in",
)
should_refresh = force_refresh or (time.time() + REFRESH_SKEW_SECONDS >= creds.expires_at)
if not should_refresh:
return creds.access_token
try:
resp = refresh_access_token(
creds.refresh_token,
client_id=creds.client_id or None,
client_secret=creds.client_secret or None,
)
except GoogleOAuthError:
raise
new_access = str(resp.get("access_token", "") or "").strip()
if not new_access:
raise GoogleOAuthError(
"Refresh response did not include an access_token.",
code="google_oauth_refresh_empty",
)
# Google sometimes rotates refresh_token; preserve existing if omitted.
new_refresh = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token
expires_in = int(resp.get("expires_in", 0) or 0)
creds.access_token = new_access
creds.refresh_token = new_refresh
creds.expires_at = time.time() + max(60, expires_in)
save_credentials(creds)
return creds.access_token
# =============================================================================
# Credential-pool-compatible variant
# =============================================================================
def run_gemini_oauth_login_pure() -> Dict[str, Any]:
"""Run the login flow and return a dict matching the credential pool shape.
This is used by `hermes auth add --provider google-gemini-cli` to register
an entry in the multi-account credential pool alongside the flat file.
"""
creds = start_oauth_flow(force_relogin=True)
return {
"access_token": creds.access_token,
"refresh_token": creds.refresh_token,
"expires_at_ms": int(creds.expires_at * 1000),
"email": creds.email,
"client_id": creds.client_id,
}

View file

@ -78,6 +78,12 @@ QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
# Google Gemini OAuth (google-gemini-cli provider)
# Targets the OpenAI-compatible Gemini endpoint (v1beta/openai). Client id is
# sourced from HERMES_GEMINI_CLIENT_ID at runtime via agent.google_oauth.
DEFAULT_GEMINI_OAUTH_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 300 # refresh 5 min before expiry
# ============================================================================= # =============================================================================
# Provider Registry # Provider Registry
@ -122,6 +128,13 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
auth_type="oauth_external", auth_type="oauth_external",
inference_base_url=DEFAULT_QWEN_BASE_URL, inference_base_url=DEFAULT_QWEN_BASE_URL,
), ),
"google-gemini-cli": ProviderConfig(
id="google-gemini-cli",
name="Google Gemini (OAuth)",
auth_type="oauth_external",
inference_base_url=DEFAULT_GEMINI_OAUTH_BASE_URL,
base_url_env_var="HERMES_GEMINI_BASE_URL",
),
"copilot": ProviderConfig( "copilot": ProviderConfig(
id="copilot", id="copilot",
name="GitHub Copilot", name="GitHub Copilot",
@ -939,7 +952,7 @@ def resolve_provider(
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
"opencode": "opencode-zen", "zen": "opencode-zen", "opencode": "opencode-zen", "zen": "opencode-zen",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli", "gemini-cli": "google-gemini-cli",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock", "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
@ -1251,6 +1264,88 @@ def get_qwen_auth_status() -> Dict[str, Any]:
} }
# =============================================================================
# Google Gemini OAuth (google-gemini-cli) — browser-based PKCE flow.
#
# Unlike qwen-oauth / openai-codex which read tokens produced by external CLI
# tools, Hermes runs the PKCE flow itself via agent.google_oauth. Tokens live in
# ~/.hermes/auth/google_oauth.json, and runtime credential resolution refreshes
# the access token 5 min before expiry on every request.
# =============================================================================
def resolve_gemini_oauth_runtime_credentials(
*,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""Resolve runtime OAuth creds for google-gemini-cli.
Raises AuthError if the user has not completed the OAuth login flow or if
refresh fails irrecoverably.
"""
try:
from agent.google_oauth import (
GoogleOAuthError,
_credentials_path,
get_valid_access_token,
load_credentials,
)
except ImportError as exc: # pragma: no cover — only hit if module is missing
raise AuthError(
f"agent.google_oauth is not importable: {exc}",
provider="google-gemini-cli",
code="google_oauth_module_missing",
) from exc
try:
access_token = get_valid_access_token(force_refresh=force_refresh)
except GoogleOAuthError as exc:
raise AuthError(
str(exc),
provider="google-gemini-cli",
code=exc.code,
) from exc
creds = load_credentials()
base_url = (
os.getenv("HERMES_GEMINI_BASE_URL", "").strip().rstrip("/")
or DEFAULT_GEMINI_OAUTH_BASE_URL
)
return {
"provider": "google-gemini-cli",
"base_url": base_url,
"api_key": access_token,
"source": "google-oauth",
"expires_at_ms": int((creds.expires_at if creds else 0) * 1000) or None,
"auth_file": str(_credentials_path()),
"email": (creds.email if creds else "") or "",
}
def get_gemini_oauth_auth_status() -> Dict[str, Any]:
"""Return a status dict for `hermes auth list` / `hermes status`."""
try:
from agent.google_oauth import _credentials_path, load_credentials
except ImportError:
return {"logged_in": False, "error": "agent.google_oauth unavailable"}
auth_path = _credentials_path()
creds = load_credentials()
if creds is None or not creds.access_token:
return {
"logged_in": False,
"auth_file": str(auth_path),
"error": "not logged in",
}
return {
"logged_in": True,
"auth_file": str(auth_path),
"source": "google-oauth",
"api_key": creds.access_token,
"expires_at_ms": int(creds.expires_at * 1000),
"email": creds.email,
}
# ============================================================================= # =============================================================================
# SSH / remote session detection # SSH / remote session detection
# ============================================================================= # =============================================================================
@ -2469,6 +2564,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_codex_auth_status() return get_codex_auth_status()
if target == "qwen-oauth": if target == "qwen-oauth":
return get_qwen_auth_status() return get_qwen_auth_status()
if target == "google-gemini-cli":
return get_gemini_oauth_auth_status()
if target == "copilot-acp": if target == "copilot-acp":
return get_external_process_provider_status(target) return get_external_process_provider_status(target)
# API-key providers # API-key providers

View file

@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
# Providers that support OAuth login in addition to API keys. # Providers that support OAuth login in addition to API keys.
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth"} _OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"}
def _get_custom_provider_names() -> list: def _get_custom_provider_names() -> list:
@ -148,7 +148,7 @@ def auth_add_command(args) -> None:
if provider.startswith(CUSTOM_POOL_PREFIX): if provider.startswith(CUSTOM_POOL_PREFIX):
requested_type = AUTH_TYPE_API_KEY requested_type = AUTH_TYPE_API_KEY
else: else:
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth"} else AUTH_TYPE_API_KEY requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY
pool = load_pool(provider) pool = load_pool(provider)
@ -254,6 +254,27 @@ def auth_add_command(args) -> None:
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return return
if provider == "google-gemini-cli":
from agent.google_oauth import run_gemini_oauth_login_pure
creds = run_gemini_oauth_login_pure()
label = (getattr(args, "label", None) or "").strip() or (
creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1)
)
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:google_pkce",
access_token=creds["access_token"],
refresh_token=creds.get("refresh_token"),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "qwen-oauth": if provider == "qwen-oauth":
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
label = (getattr(args, "label", None) or "").strip() or label_from_token( label = (getattr(args, "label", None) or "").strip() or label_from_token(

View file

@ -1002,6 +1002,30 @@ OPTIONAL_ENV_VARS = {
"category": "provider", "category": "provider",
"advanced": True, "advanced": True,
}, },
"HERMES_GEMINI_CLIENT_ID": {
"description": "Google OAuth client ID for google-gemini-cli (required for OAuth login)",
"prompt": "Google OAuth client ID (Desktop-type)",
"url": "https://console.cloud.google.com/apis/credentials",
"password": False,
"category": "provider",
"advanced": True,
},
"HERMES_GEMINI_CLIENT_SECRET": {
"description": "Google OAuth client secret for google-gemini-cli (optional; Desktop clients may skip)",
"prompt": "Google OAuth client secret (optional)",
"url": "https://console.cloud.google.com/apis/credentials",
"password": True,
"category": "provider",
"advanced": True,
},
"HERMES_GEMINI_BASE_URL": {
"description": "Gemini OpenAI-compatible endpoint override (default: v1beta/openai)",
"prompt": "Gemini base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OPENCODE_ZEN_API_KEY": { "OPENCODE_ZEN_API_KEY": {
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)", "description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
"prompt": "OpenCode Zen API key", "prompt": "OpenCode Zen API key",

View file

@ -373,7 +373,11 @@ def run_doctor(args):
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
try: try:
from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status from hermes_cli.auth import (
get_nous_auth_status,
get_codex_auth_status,
get_gemini_oauth_auth_status,
)
nous_status = get_nous_auth_status() nous_status = get_nous_auth_status()
if nous_status.get("logged_in"): if nous_status.get("logged_in"):
@ -388,6 +392,14 @@ def run_doctor(args):
check_warn("OpenAI Codex auth", "(not logged in)") check_warn("OpenAI Codex auth", "(not logged in)")
if codex_status.get("error"): if codex_status.get("error"):
check_info(codex_status["error"]) check_info(codex_status["error"])
gemini_status = get_gemini_oauth_auth_status()
if gemini_status.get("logged_in"):
email = gemini_status.get("email") or ""
suffix = f" as {email}" if email else ""
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
else:
check_warn("Google Gemini OAuth", "(not logged in)")
except Exception as e: except Exception as e:
check_warn("Auth provider status", f"(could not check: {e})") check_warn("Auth provider status", f"(could not check: {e})")

View file

@ -1118,6 +1118,8 @@ def select_provider_and_model(args=None):
_model_flow_openai_codex(config, current_model) _model_flow_openai_codex(config, current_model)
elif selected_provider == "qwen-oauth": elif selected_provider == "qwen-oauth":
_model_flow_qwen_oauth(config, current_model) _model_flow_qwen_oauth(config, current_model)
elif selected_provider == "google-gemini-cli":
_model_flow_google_gemini_cli(config, current_model)
elif selected_provider == "copilot-acp": elif selected_provider == "copilot-acp":
_model_flow_copilot_acp(config, current_model) _model_flow_copilot_acp(config, current_model)
elif selected_provider == "copilot": elif selected_provider == "copilot":
@ -1520,6 +1522,63 @@ def _model_flow_qwen_oauth(_config, current_model=""):
print("No change.") print("No change.")
def _model_flow_google_gemini_cli(_config, current_model=""):
"""Google Gemini OAuth (PKCE): runs the browser login, then picks a model.
The flow:
1. If no creds are on disk (or --force), run the PKCE flow via
``agent.google_oauth.start_oauth_flow``.
2. Validate the access token.
3. Prompt for a model from the curated list.
4. Save selection to ``~/.hermes/config.yaml``.
"""
from hermes_cli.auth import (
DEFAULT_GEMINI_OAUTH_BASE_URL,
get_gemini_oauth_auth_status,
resolve_gemini_oauth_runtime_credentials,
_prompt_model_selection,
_save_model_choice,
_update_config_for_provider,
)
from hermes_cli.models import _PROVIDER_MODELS
status = get_gemini_oauth_auth_status()
if not status.get("logged_in"):
print("Not logged into Google Gemini OAuth.")
if status.get("error"):
print(f" {status['error']}")
choice = input("Run OAuth login now? [Y/n]: ").strip().lower()
if choice in {"", "y", "yes"}:
try:
from agent.google_oauth import start_oauth_flow
start_oauth_flow(force_relogin=True)
except Exception as exc:
print(f"OAuth login failed: {exc}")
return
else:
print("Skipping login; no change.")
return
# Verify creds resolve
try:
resolve_gemini_oauth_runtime_credentials(force_refresh=False)
except Exception as exc:
print(f"Failed to resolve Gemini OAuth credentials: {exc}")
return
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-2.5-flash")
selected = _prompt_model_selection(models, current_model=default)
if selected:
_save_model_choice(selected)
_update_config_for_provider("google-gemini-cli", DEFAULT_GEMINI_OAUTH_BASE_URL)
print(f"Default model set to: {selected} (via Google Gemini OAuth)")
else:
print("No change.")
def _model_flow_custom(config): def _model_flow_custom(config):
"""Custom endpoint: collect URL, API key, and model name. """Custom endpoint: collect URL, API key, and model name.

View file

@ -136,6 +136,16 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemma-4-31b-it", "gemma-4-31b-it",
"gemma-4-26b-it", "gemma-4-26b-it",
], ],
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemma-4-31b-it",
"gemma-4-26b-it",
],
"zai": [ "zai": [
"glm-5.1", "glm-5.1",
"glm-5", "glm-5",
@ -534,6 +544,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"), ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"), ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth (PKCE login; no API key needed)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"), ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
@ -596,6 +607,9 @@ _PROVIDER_ALIASES = {
"qwen": "alibaba", "qwen": "alibaba",
"alibaba-cloud": "alibaba", "alibaba-cloud": "alibaba",
"qwen-portal": "qwen-oauth", "qwen-portal": "qwen-oauth",
"gemini-cli": "google-gemini-cli",
"gemini-oauth": "google-gemini-cli",
"hf": "huggingface", "hf": "huggingface",
"hugging-face": "huggingface", "hugging-face": "huggingface",
"huggingface-hub": "huggingface", "huggingface-hub": "huggingface",

View file

@ -64,6 +64,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
base_url_override="https://portal.qwen.ai/v1", base_url_override="https://portal.qwen.ai/v1",
base_url_env_var="HERMES_QWEN_BASE_URL", base_url_env_var="HERMES_QWEN_BASE_URL",
), ),
"google-gemini-cli": HermesOverlay(
transport="openai_chat",
auth_type="oauth_external",
base_url_override="https://generativelanguage.googleapis.com/v1beta/openai",
base_url_env_var="HERMES_GEMINI_BASE_URL",
),
"copilot-acp": HermesOverlay( "copilot-acp": HermesOverlay(
transport="codex_responses", transport="codex_responses",
auth_type="external_process", auth_type="external_process",
@ -232,6 +238,13 @@ ALIASES: Dict[str, str] = {
"qwen": "alibaba", "qwen": "alibaba",
"alibaba-cloud": "alibaba", "alibaba-cloud": "alibaba",
# google-gemini-cli (OAuth-auth'd Gemini via PKCE)
"gemini-cli": "google-gemini-cli",
"gemini-oauth": "google-gemini-cli",
"google-oauth": "google-gemini-cli",
# huggingface # huggingface
"hf": "huggingface", "hf": "huggingface",
"hugging-face": "huggingface", "hugging-face": "huggingface",

View file

@ -22,6 +22,7 @@ from hermes_cli.auth import (
resolve_nous_runtime_credentials, resolve_nous_runtime_credentials,
resolve_codex_runtime_credentials, resolve_codex_runtime_credentials,
resolve_qwen_runtime_credentials, resolve_qwen_runtime_credentials,
resolve_gemini_oauth_runtime_credentials,
resolve_api_key_provider_credentials, resolve_api_key_provider_credentials,
resolve_external_process_provider_credentials, resolve_external_process_provider_credentials,
has_usable_secret, has_usable_secret,
@ -156,6 +157,9 @@ def _resolve_runtime_from_pool_entry(
elif provider == "qwen-oauth": elif provider == "qwen-oauth":
api_mode = "chat_completions" api_mode = "chat_completions"
base_url = base_url or DEFAULT_QWEN_BASE_URL base_url = base_url or DEFAULT_QWEN_BASE_URL
elif provider == "google-gemini-cli":
api_mode = "chat_completions"
base_url = base_url or "https://generativelanguage.googleapis.com/v1beta/openai"
elif provider == "anthropic": elif provider == "anthropic":
api_mode = "anthropic_messages" api_mode = "anthropic_messages"
cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
@ -804,6 +808,25 @@ def resolve_runtime_provider(
logger.info("Qwen OAuth credentials failed; " logger.info("Qwen OAuth credentials failed; "
"falling through to next provider.") "falling through to next provider.")
if provider == "google-gemini-cli":
try:
creds = resolve_gemini_oauth_runtime_credentials()
return {
"provider": "google-gemini-cli",
"api_mode": "chat_completions",
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "google-oauth"),
"expires_at_ms": creds.get("expires_at_ms"),
"email": creds.get("email", ""),
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
raise
logger.info("Google Gemini OAuth credentials failed; "
"falling through to next provider.")
if provider == "copilot-acp": if provider == "copilot-acp":
creds = resolve_external_process_provider_credentials(provider) creds = resolve_external_process_provider_credentials(provider)
return { return {

View file

@ -0,0 +1,570 @@
"""Tests for the Google OAuth (google-gemini-cli) provider.
Covers:
- PKCE generation (S256 roundtrip)
- Credential save/load/clear with 0o600 permissions, atomic write
- Token exchange + refresh (success + failure)
- ``get_valid_access_token`` fresh / near-expiry / force-refresh
- Refresh-token rotation handling (preserves old when Google omits new)
- Cross-process file lock acquires and releases cleanly
- Port fallback when the preferred callback port is busy
- Manual paste fallback parses both full redirect URLs and bare codes
- Runtime provider resolution + AuthError code propagation
- get_auth_status dispatch
- _OAUTH_CAPABLE_PROVIDERS includes google-gemini-cli (and preserves existing)
- Aliases resolve to canonical slug
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import socket
import stat
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch, tmp_path):
"""Redirect HERMES_HOME and clear Gemini env vars for every test."""
home = tmp_path / ".hermes"
home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
for key in (
"HERMES_GEMINI_CLIENT_ID",
"HERMES_GEMINI_CLIENT_SECRET",
"HERMES_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
):
monkeypatch.delenv(key, raising=False)
return home
# =============================================================================
# PKCE
# =============================================================================
class TestPkce:
def test_verifier_and_challenge_are_related_via_s256(self):
from agent.google_oauth import _generate_pkce_pair
verifier, challenge = _generate_pkce_pair()
expected = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")
assert challenge == expected
def test_verifier_is_url_safe(self):
from agent.google_oauth import _generate_pkce_pair
verifier, _ = _generate_pkce_pair()
# Per RFC 7636: url-safe base64 without padding, 43128 chars
assert 43 <= len(verifier) <= 128
allowed = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
assert set(verifier).issubset(allowed)
def test_pairs_are_unique_across_calls(self):
from agent.google_oauth import _generate_pkce_pair
pairs = {_generate_pkce_pair()[0] for _ in range(20)}
assert len(pairs) == 20
# =============================================================================
# Credential I/O
# =============================================================================
class TestCredentialIo:
def _make(self):
from agent.google_oauth import GoogleCredentials
return GoogleCredentials(
access_token="at-1",
refresh_token="rt-1",
expires_at=time.time() + 3600,
client_id="client-123",
client_secret="secret-456",
email="user@example.com",
)
def test_save_and_load_roundtrip(self):
from agent.google_oauth import load_credentials, save_credentials
creds = self._make()
path = save_credentials(creds)
loaded = load_credentials()
assert loaded is not None
assert loaded.access_token == creds.access_token
assert loaded.refresh_token == creds.refresh_token
assert loaded.email == creds.email
assert path.exists()
def test_save_uses_0o600_permissions(self):
from agent.google_oauth import save_credentials
creds = self._make()
path = save_credentials(creds)
mode = stat.S_IMODE(path.stat().st_mode)
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
def test_load_returns_none_when_missing(self):
from agent.google_oauth import load_credentials
assert load_credentials() is None
def test_load_returns_none_on_corrupt_json(self):
from agent.google_oauth import _credentials_path, load_credentials
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("not json")
assert load_credentials() is None
def test_load_returns_none_when_access_token_empty(self):
from agent.google_oauth import _credentials_path, load_credentials
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"access_token": "", "refresh_token": "x"}))
assert load_credentials() is None
def test_clear_is_idempotent(self):
from agent.google_oauth import clear_credentials, save_credentials
save_credentials(self._make())
clear_credentials()
clear_credentials() # should not raise
def test_atomic_write_leaves_no_tmp_file(self):
from agent.google_oauth import _credentials_path, save_credentials
save_credentials(self._make())
path = _credentials_path()
leftovers = list(path.parent.glob("*.tmp.*"))
assert leftovers == []
# =============================================================================
# Cross-process lock
# =============================================================================
class TestCrossProcessLock:
def test_lock_acquires_and_releases(self):
from agent.google_oauth import _credentials_lock, _lock_path
with _credentials_lock():
assert _lock_path().exists()
# After release, a second acquisition should succeed immediately
with _credentials_lock(timeout_seconds=1.0):
pass
def test_lock_is_reentrant_within_thread(self):
from agent.google_oauth import _credentials_lock
with _credentials_lock():
with _credentials_lock():
with _credentials_lock():
pass
# =============================================================================
# Client credential resolution
# =============================================================================
class TestClientIdResolution:
def test_env_var_overrides_default(self, monkeypatch):
from agent.google_oauth import _get_client_id
monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "env-client-xyz")
assert _get_client_id() == "env-client-xyz"
def test_missing_client_id_raises(self):
from agent.google_oauth import GoogleOAuthError, _require_client_id
with pytest.raises(GoogleOAuthError) as exc_info:
_require_client_id()
assert exc_info.value.code == "google_oauth_client_id_missing"
# =============================================================================
# Token exchange + refresh
# =============================================================================
class TestTokenExchange:
def test_exchange_code_sends_correct_body(self, monkeypatch):
from agent import google_oauth
captured = {}
def fake_post(url, data, timeout):
captured["url"] = url
captured["data"] = data
return {"access_token": "at", "refresh_token": "rt", "expires_in": 3600}
monkeypatch.setattr(google_oauth, "_post_form", fake_post)
monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "cid-123")
google_oauth.exchange_code(
code="auth-code-abc",
verifier="verifier-xyz",
redirect_uri="http://127.0.0.1:8085/oauth2callback",
)
assert captured["data"]["grant_type"] == "authorization_code"
assert captured["data"]["code"] == "auth-code-abc"
assert captured["data"]["code_verifier"] == "verifier-xyz"
assert captured["data"]["client_id"] == "cid-123"
def test_refresh_access_token_success(self, monkeypatch):
from agent import google_oauth
monkeypatch.setattr(
google_oauth, "_post_form",
lambda *a, **kw: {"access_token": "new-at", "expires_in": 3600},
)
monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "cid")
resp = google_oauth.refresh_access_token("refresh-abc")
assert resp["access_token"] == "new-at"
def test_refresh_without_refresh_token_raises(self):
from agent.google_oauth import GoogleOAuthError, refresh_access_token
with pytest.raises(GoogleOAuthError) as exc_info:
refresh_access_token("")
assert exc_info.value.code == "google_oauth_refresh_token_missing"
# =============================================================================
# get_valid_access_token
# =============================================================================
class TestGetValidAccessToken:
def _save(self, **overrides):
from agent.google_oauth import GoogleCredentials, save_credentials
defaults = {
"access_token": "current-at",
"refresh_token": "rt-1",
"expires_at": time.time() + 3600,
"client_id": "cid",
"client_secret": "",
}
defaults.update(overrides)
save_credentials(GoogleCredentials(**defaults))
def test_returns_cached_token_when_fresh(self):
from agent.google_oauth import get_valid_access_token
self._save(expires_at=time.time() + 3600)
token = get_valid_access_token()
assert token == "current-at"
def test_refreshes_when_near_expiry(self, monkeypatch):
from agent import google_oauth
self._save(expires_at=time.time() + 30) # within 5-min skew
monkeypatch.setattr(
google_oauth, "_post_form",
lambda *a, **kw: {"access_token": "refreshed-at", "expires_in": 3600},
)
token = google_oauth.get_valid_access_token()
assert token == "refreshed-at"
# Reloaded creds should have new access_token
loaded = google_oauth.load_credentials()
assert loaded.access_token == "refreshed-at"
def test_force_refresh_ignores_expiry(self, monkeypatch):
from agent import google_oauth
self._save(expires_at=time.time() + 3600) # plenty of time left
monkeypatch.setattr(
google_oauth, "_post_form",
lambda *a, **kw: {"access_token": "forced-at", "expires_in": 3600},
)
token = google_oauth.get_valid_access_token(force_refresh=True)
assert token == "forced-at"
def test_raises_when_not_logged_in(self):
from agent.google_oauth import GoogleOAuthError, get_valid_access_token
with pytest.raises(GoogleOAuthError) as exc_info:
get_valid_access_token()
assert exc_info.value.code == "google_oauth_not_logged_in"
def test_preserves_refresh_token_when_google_omits_new_one(self, monkeypatch):
"""Google sometimes omits refresh_token from refresh responses; keep the old one."""
from agent import google_oauth
self._save(expires_at=time.time() + 30, refresh_token="original-rt")
# Refresh response has no refresh_token field
monkeypatch.setattr(
google_oauth, "_post_form",
lambda *a, **kw: {"access_token": "new-at", "expires_in": 3600},
)
google_oauth.get_valid_access_token()
loaded = google_oauth.load_credentials()
assert loaded.refresh_token == "original-rt"
def test_rotates_refresh_token_when_google_returns_new_one(self, monkeypatch):
from agent import google_oauth
self._save(expires_at=time.time() + 30, refresh_token="original-rt")
monkeypatch.setattr(
google_oauth, "_post_form",
lambda *a, **kw: {
"access_token": "new-at",
"refresh_token": "rotated-rt",
"expires_in": 3600,
},
)
google_oauth.get_valid_access_token()
loaded = google_oauth.load_credentials()
assert loaded.refresh_token == "rotated-rt"
# =============================================================================
# Callback server port fallback
# =============================================================================
class TestCallbackServer:
def test_binds_preferred_port_when_free(self):
from agent.google_oauth import _bind_callback_server
# Find an unused port in the 50000-60000 range so we don't collide with
# real services even on busy dev machines.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server, actual_port = _bind_callback_server(preferred_port=port)
try:
assert actual_port == port
finally:
server.server_close()
def test_falls_back_to_ephemeral_when_preferred_busy(self):
from agent.google_oauth import _bind_callback_server
# Occupy a port so binding to it fails
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as blocker:
blocker.bind(("127.0.0.1", 0))
blocker.listen(1)
busy_port = blocker.getsockname()[1]
server, actual_port = _bind_callback_server(preferred_port=busy_port)
try:
assert actual_port != busy_port
assert actual_port > 0
finally:
server.server_close()
# =============================================================================
# Manual paste fallback
# =============================================================================
class TestPasteFallback:
def test_accepts_full_redirect_url(self, monkeypatch):
from agent import google_oauth
pasted = "http://127.0.0.1:8085/oauth2callback?code=abc123&state=xyz&scope=..."
monkeypatch.setattr("builtins.input", lambda *_: pasted)
assert google_oauth._prompt_paste_fallback() == "abc123"
def test_accepts_bare_code(self, monkeypatch):
from agent import google_oauth
monkeypatch.setattr("builtins.input", lambda *_: "raw-code-xyz")
assert google_oauth._prompt_paste_fallback() == "raw-code-xyz"
def test_empty_input_returns_none(self, monkeypatch):
from agent import google_oauth
monkeypatch.setattr("builtins.input", lambda *_: " ")
assert google_oauth._prompt_paste_fallback() is None
# =============================================================================
# Runtime provider integration
# =============================================================================
class TestRuntimeProvider:
def test_resolves_when_valid_token_exists(self):
from agent.google_oauth import GoogleCredentials, save_credentials
from hermes_cli.auth import resolve_gemini_oauth_runtime_credentials
save_credentials(GoogleCredentials(
access_token="live-token",
refresh_token="rt",
expires_at=time.time() + 3600,
client_id="cid",
email="u@e.com",
))
creds = resolve_gemini_oauth_runtime_credentials()
assert creds["provider"] == "google-gemini-cli"
assert creds["api_key"] == "live-token"
assert creds["source"] == "google-oauth"
assert "generativelanguage.googleapis.com" in creds["base_url"]
assert creds["email"] == "u@e.com"
def test_raises_autherror_when_not_logged_in(self):
from hermes_cli.auth import AuthError, resolve_gemini_oauth_runtime_credentials
with pytest.raises(AuthError) as exc_info:
resolve_gemini_oauth_runtime_credentials()
assert exc_info.value.code == "google_oauth_not_logged_in"
def test_runtime_provider_dispatches_gemini(self):
from agent.google_oauth import GoogleCredentials, save_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
save_credentials(GoogleCredentials(
access_token="tok",
refresh_token="rt",
expires_at=time.time() + 3600,
client_id="cid",
))
result = resolve_runtime_provider(requested="google-gemini-cli")
assert result["provider"] == "google-gemini-cli"
assert result["api_mode"] == "chat_completions"
assert result["api_key"] == "tok"
def test_base_url_env_override(self, monkeypatch):
from agent.google_oauth import GoogleCredentials, save_credentials
from hermes_cli.auth import resolve_gemini_oauth_runtime_credentials
monkeypatch.setenv("HERMES_GEMINI_BASE_URL", "https://custom.example/v1")
save_credentials(GoogleCredentials(
access_token="tok", refresh_token="rt",
expires_at=time.time() + 3600, client_id="cid",
))
creds = resolve_gemini_oauth_runtime_credentials()
assert creds["base_url"] == "https://custom.example/v1"
# =============================================================================
# Provider registration touchpoints
# =============================================================================
class TestProviderRegistration:
def test_registry_entry_exists(self):
from hermes_cli.auth import PROVIDER_REGISTRY
assert "google-gemini-cli" in PROVIDER_REGISTRY
pc = PROVIDER_REGISTRY["google-gemini-cli"]
assert pc.auth_type == "oauth_external"
assert "generativelanguage.googleapis.com" in pc.inference_base_url
@pytest.mark.parametrize("alias", [
"gemini-cli", "gemini-oauth", "google-gemini-cli",
])
def test_aliases_resolve(self, alias):
from hermes_cli.auth import resolve_provider
assert resolve_provider(alias) == "google-gemini-cli"
def test_models_catalog_populated(self):
from hermes_cli.models import _PROVIDER_MODELS, CANONICAL_PROVIDERS
assert len(_PROVIDER_MODELS["google-gemini-cli"]) >= 5
assert any(p.slug == "google-gemini-cli" for p in CANONICAL_PROVIDERS)
def test_determine_api_mode_returns_chat_completions(self):
from hermes_cli.providers import determine_api_mode
mode = determine_api_mode(
"google-gemini-cli",
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
)
assert mode == "chat_completions"
def test_oauth_capable_set_preserves_existing_providers(self):
"""PR #10779 regressed this — make sure we DIDN'T drop anthropic/nous."""
from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS
for required in ("anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"):
assert required in _OAUTH_CAPABLE_PROVIDERS, \
f"{required} missing from _OAUTH_CAPABLE_PROVIDERS"
def test_config_env_vars_registered(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
for key in (
"HERMES_GEMINI_CLIENT_ID",
"HERMES_GEMINI_CLIENT_SECRET",
"HERMES_GEMINI_BASE_URL",
):
assert key in OPTIONAL_ENV_VARS
# =============================================================================
# Auth status dispatch
# =============================================================================
class TestAuthStatus:
def test_status_when_not_logged_in(self):
from hermes_cli.auth import get_auth_status
status = get_auth_status("google-gemini-cli")
assert status["logged_in"] is False
def test_status_when_logged_in(self):
from agent.google_oauth import GoogleCredentials, save_credentials
from hermes_cli.auth import get_auth_status
save_credentials(GoogleCredentials(
access_token="tok", refresh_token="rt",
expires_at=time.time() + 3600, client_id="cid",
email="tek@nous.ai",
))
status = get_auth_status("google-gemini-cli")
assert status["logged_in"] is True
assert status["source"] == "google-oauth"
assert status["email"] == "tek@nous.ai"
# =============================================================================
# run_gemini_oauth_login_pure
# =============================================================================
class TestOauthLoginPure:
def test_returns_pool_compatible_dict(self, monkeypatch):
from agent import google_oauth
def fake_start(**kw):
return google_oauth.GoogleCredentials(
access_token="at", refresh_token="rt",
expires_at=time.time() + 3600,
client_id="cid", email="u@e.com",
)
monkeypatch.setattr(google_oauth, "start_oauth_flow", fake_start)
result = google_oauth.run_gemini_oauth_login_pure()
assert result["access_token"] == "at"
assert result["refresh_token"] == "rt"
assert "expires_at_ms" in result
assert isinstance(result["expires_at_ms"], int)
assert result["email"] == "u@e.com"

View file

@ -35,12 +35,68 @@ 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`) | | **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
| **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) | | **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 / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) |
| **Google Gemini (OAuth)** | `hermes model` → "Google Gemini (OAuth)" (provider: `google-gemini-cli`, browser PKCE login, requires `HERMES_GEMINI_CLIENT_ID`) |
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) | | **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
:::tip Model key alias :::tip Model key alias
In the `model:` config section, you can use either `default:` or `model:` as the key name for your model ID. Both `model: { default: my-model }` and `model: { model: my-model }` work identically. In the `model:` config section, you can use either `default:` or `model:` as the key name for your model ID. Both `model: { default: my-model }` and `model: { model: my-model }` work identically.
::: :::
### Google Gemini via OAuth (`google-gemini-cli`)
The `google-gemini-cli` provider lets you authenticate with your Google account
via a browser-based Authorization Code + PKCE flow — no API key copy-paste, and
credentials are refreshed automatically before every request.
**Quick start:**
```bash
# 1. Set your OAuth client ID (see "Registering a Desktop OAuth client" below)
export HERMES_GEMINI_CLIENT_ID="your-client-id.apps.googleusercontent.com"
# 2. Run the login flow
hermes model
# → pick "Google Gemini (OAuth)"
# → a browser opens to accounts.google.com, sign in, Hermes captures the callback
# 3. Chat as normal
hermes chat
```
**Storage:** tokens are persisted to `~/.hermes/auth/google_oauth.json` with
0600 permissions, atomic writes, and a cross-process fcntl lock so multiple
Hermes instances can safely share a session.
**Endpoint:** requests are routed to Google's OpenAI-compatible Gemini endpoint
(`https://generativelanguage.googleapis.com/v1beta/openai`) with a Bearer
access token. Supports the full Gemini 2.5 / 3.x lineup and Gemma open models.
#### Registering a Desktop OAuth client
Hermes does not ship with a default OAuth client ID — you register one yourself
in Google Cloud Console so quota and consent screens are scoped to your
organization:
1. Go to <https://console.cloud.google.com/apis/credentials>.
2. Create (or pick) a project and click **"Create Credentials" → "OAuth client ID"**.
3. Choose **Application type: Desktop app**, name it "Hermes Agent".
4. Enable the **Generative Language API** for the project under APIs & Services.
5. Download the JSON and set `HERMES_GEMINI_CLIENT_ID` in `~/.hermes/.env`
(client secret is optional for Desktop clients but can be set via
`HERMES_GEMINI_CLIENT_SECRET` if required by your org policy).
#### Troubleshooting
- **Port 8085 already in use** — Hermes will automatically fall back to an
ephemeral port. Add that exact URL to your OAuth client's authorized redirect
URIs if Google refuses it.
- **"State mismatch — aborting for safety"** — someone hit the callback URL
with a stale/forged request. Re-run the login.
- **Refresh failures persist** — re-run login (`hermes auth add --provider
google-gemini-cli`); stale refresh tokens can happen after password changes
or scope revocation.
:::info Codex Note :::info Codex Note
The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required. The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required.
::: :::

View file

@ -47,6 +47,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
| `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) | | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) |
| `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | | `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` |
| `GEMINI_BASE_URL` | Override Google AI Studio base URL | | `GEMINI_BASE_URL` | Override Google AI Studio base URL |
| `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (required — register a Desktop client at [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)) |
| `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional for Desktop clients) |
| `HERMES_GEMINI_BASE_URL` | Override the Gemini OAuth inference endpoint (default: `https://generativelanguage.googleapis.com/v1beta/openai`) |
| `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |
| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |
| `DASHSCOPE_API_KEY` | Alibaba Cloud DashScope API key for Qwen models ([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) | | `DASHSCOPE_API_KEY` | Alibaba Cloud DashScope API key for Qwen models ([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) |