feat(providers): remove google-gemini-cli + google-antigravity OAuth providers (#50492)

* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers

Google now actively bans accounts for third-party tools that piggyback on
Gemini CLI / Antigravity / Code Assist OAuth, and because abuse prevention
sits at a backend layer the ban can extend to the entire Google account
(Gmail/Drive), with a second violation being permanent.
Ref: https://github.com/google-gemini/gemini-cli/discussions/20632

Removes both OAuth inference providers entirely (modules, provider profiles,
auth/runtime/config/models wiring, the /gquota Code Assist quota command,
the antigravity-cli optional skill, desktop + docs surface in en + zh-Hans).
The API-key 'gemini' provider (GOOGLE_API_KEY/GEMINI_API_KEY against
generativelanguage.googleapis.com) is unaffected and stays fully supported.

* fix(skills): keep the antigravity-cli skill — only the OAuth provider is removed

The antigravity-cli optional skill orchestrates the external `agy` binary as
a coding-agent tool via the terminal tool — it does NOT wrap Hermes inference
through the banned google-antigravity OAuth provider, so it carries none of
the account-ban risk that motivated removing that provider. Restore the skill,
its docs page, the sidebar entry, and the optional-skills catalog row. The
google-antigravity / google-gemini-cli inference providers stay fully removed.
fix/verification-admin-route-recovery
Teknium 2026-06-21 19:53:27 -07:00 committed by GitHub
parent 5bf23ff251
commit 7130d60861
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 53 additions and 7185 deletions

View File

@ -1378,37 +1378,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
agent._client_log_context(),
)
return client
if agent.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
# Strip OpenAI-specific kwargs the Gemini client doesn't accept
safe_kwargs = {
k: v for k, v in client_kwargs.items()
if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
}
client = GeminiCloudCodeClient(**safe_kwargs)
_ra().logger.info(
"Gemini Cloud Code Assist client created (%s, shared=%s) %s",
reason,
shared,
agent._client_log_context(),
)
return client
if agent.provider == "google-antigravity" or str(client_kwargs.get("base_url", "")).startswith("antigravity-pa://"):
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
safe_kwargs = {
k: v for k, v in client_kwargs.items()
if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
}
client = AntigravityCloudCodeClient(**safe_kwargs)
_ra().logger.info(
"Antigravity Code Assist client created (%s, shared=%s) %s",
reason,
shared,
agent._client_log_context(),
)
return client
if agent.provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url

View File

@ -1,164 +0,0 @@
"""OpenAI-compatible facade for Antigravity native OAuth inference."""
from __future__ import annotations
from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent import antigravity_oauth
from agent.antigravity_code_assist import (
ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
CodeAssistError,
ProjectContext,
build_headers,
resolve_project_context,
)
from agent.gemini_cloudcode_adapter import (
GeminiCloudCodeClient,
_GeminiStreamChunk,
_gemini_http_error,
_iter_sse_events,
_translate_gemini_response,
_translate_stream_event,
build_gemini_request,
wrap_code_assist_request,
)
MARKER_BASE_URL = "antigravity-pa://google"
class AntigravityCloudCodeClient(GeminiCloudCodeClient):
"""Minimal OpenAI-SDK-compatible facade over Antigravity Code Assist."""
def __init__(
self,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
default_headers: Optional[Dict[str, str]] = None,
project_id: str = "",
**kwargs: Any,
):
super().__init__(
api_key=api_key or "antigravity-oauth",
base_url=base_url or MARKER_BASE_URL,
default_headers=default_headers,
project_id=project_id,
**kwargs,
)
def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
if self._project_context is not None:
return self._project_context # type: ignore[return-value]
env_project = antigravity_oauth.resolve_project_id_from_env()
creds = antigravity_oauth.load_credentials()
stored_project = creds.project_id if creds else ""
if stored_project:
self._project_context = ProjectContext(
project_id=stored_project,
managed_project_id=creds.managed_project_id if creds else "",
source="stored",
)
return self._project_context
ctx = resolve_project_context(
access_token,
configured_project_id=self._configured_project_id,
env_project_id=env_project,
)
if ctx.project_id or ctx.managed_project_id:
antigravity_oauth.update_project_ids(
project_id=ctx.project_id,
managed_project_id=ctx.managed_project_id,
)
self._project_context = ctx
return ctx
def _create_chat_completion(
self,
*,
model: str = "gemini-3-flash-agent",
messages: Optional[List[Dict[str, Any]]] = None,
stream: bool = False,
tools: Any = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stop: Any = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Any = None,
**_: Any,
) -> Any:
access_token = antigravity_oauth.get_valid_access_token()
ctx = self._ensure_project_context(access_token, model)
thinking_config = None
if isinstance(extra_body, dict):
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
inner = build_gemini_request(
messages=messages or [],
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
stop=stop,
thinking_config=thinking_config,
)
wrapped = wrap_code_assist_request(
project_id=ctx.project_id,
model=model,
inner_request=inner,
)
headers = build_headers(access_token)
headers.update(self._default_headers)
if stream:
return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
url = f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
response = self._http.post(url, json=wrapped, headers=headers)
if response.status_code != 200:
raise _gemini_http_error(response)
try:
payload = response.json()
except ValueError as exc:
raise CodeAssistError(
f"Invalid JSON from Antigravity Code Assist: {exc}",
code="antigravity_code_assist_invalid_json",
) from exc
return _translate_gemini_response(payload, model=model)
def _stream_completion(
self,
*,
model: str,
wrapped: Dict[str, Any],
headers: Dict[str, str],
) -> Iterator[_GeminiStreamChunk]:
url = f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse"
stream_headers = dict(headers)
stream_headers["Accept"] = "text/event-stream"
def _generator() -> Iterator[_GeminiStreamChunk]:
try:
with self._http.stream("POST", url, json=wrapped, headers=stream_headers) as response:
if response.status_code != 200:
response.read()
raise _gemini_http_error(response)
tool_call_counter: List[int] = [0]
for event in _iter_sse_events(response):
for chunk in _translate_stream_event(event, model, tool_call_counter):
yield chunk
except httpx.HTTPError as exc:
raise CodeAssistError(
f"Antigravity streaming request failed: {exc}",
code="antigravity_code_assist_stream_error",
) from exc
return _generator()

View File

@ -1,286 +0,0 @@
"""Antigravity Code Assist control-plane helpers.
The new Antigravity CLI uses the same v1internal Code Assist family as
gemini-cli, but with Antigravity OAuth scopes, metadata and model catalog. This
module keeps that provider-specific surface separate from
``agent.google_code_assist``.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional
from agent.google_code_assist import CodeAssistError
logger = logging.getLogger(__name__)
ANTIGRAVITY_CODE_ASSIST_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
ANTIGRAVITY_MODEL_ENDPOINTS = [
ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
"https://cloudcode-pa.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
]
ANTIGRAVITY_CLIENT_METADATA = {
"ideType": "ANTIGRAVITY",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
}
ANTIGRAVITY_USER_AGENT = "antigravity/1.0.0 windows/amd64"
ANTIGRAVITY_X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"
DEFAULT_AGENT_MODEL_IDS = [
"gemini-3-flash-agent",
"gemini-3.5-flash-low",
"gemini-pro-agent",
"gemini-3.1-pro-low",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
"gpt-oss-120b-medium",
]
DEPRECATED_MODEL_REPLACEMENTS = {
"gemini-3.1-pro-high": "gemini-pro-agent",
}
@dataclass
class AntigravityProjectInfo:
project_id: str = ""
raw: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ProjectContext:
project_id: str = ""
managed_project_id: str = ""
tier_id: str = ""
source: str = ""
def _client_metadata() -> Dict[str, str]:
return dict(ANTIGRAVITY_CLIENT_METADATA)
def build_headers(access_token: str, *, accept: str = "application/json") -> Dict[str, str]:
return {
"Content-Type": "application/json",
"Accept": accept,
"Authorization": f"Bearer {access_token}",
"User-Agent": ANTIGRAVITY_USER_AGENT,
"X-Goog-Api-Client": ANTIGRAVITY_X_GOOG_API_CLIENT,
"Client-Metadata": json.dumps(_client_metadata(), separators=(",", ":")),
"x-activity-request-id": str(uuid.uuid4()),
}
def _post_json(
url: str,
body: Dict[str, Any],
access_token: str,
*,
timeout: float = 30.0,
) -> Dict[str, Any]:
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
method="POST",
headers=build_headers(access_token),
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", errors="replace")
except Exception:
pass
raise CodeAssistError(
f"Antigravity Code Assist HTTP {exc.code}: {detail or exc.reason}",
code=f"antigravity_code_assist_http_{exc.code}",
) from exc
except urllib.error.URLError as exc:
raise CodeAssistError(
f"Antigravity Code Assist request failed: {exc}",
code="antigravity_code_assist_network_error",
) from exc
def load_code_assist(
access_token: str,
*,
project_id: str = "",
endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
) -> AntigravityProjectInfo:
metadata = _client_metadata()
if project_id:
metadata["duetProject"] = project_id
body: Dict[str, Any] = {"metadata": metadata}
if project_id:
body["cloudaicompanionProject"] = project_id
resp = _post_json(f"{endpoint}/v1internal:loadCodeAssist", body, access_token)
project = (
str(resp.get("cloudaicompanionProject") or "").strip()
or str(resp.get("project") or "").strip()
)
return AntigravityProjectInfo(project_id=project, raw=resp)
def resolve_project_context(
access_token: str,
*,
configured_project_id: str = "",
env_project_id: str = "",
) -> ProjectContext:
if configured_project_id:
return ProjectContext(project_id=configured_project_id, source="config")
if env_project_id:
return ProjectContext(project_id=env_project_id, source="env")
info = load_code_assist(access_token)
if info.project_id:
return ProjectContext(
project_id=info.project_id,
managed_project_id=info.project_id,
source="discovered",
)
# Discovery returned no project (common on fresh consumer accounts that
# haven't been onboarded). Fall back to the public default project so the
# call chain still succeeds — mirrors the Antigravity CLI reference flow.
from agent.antigravity_oauth import DEFAULT_PROJECT_ID
return ProjectContext(
project_id=DEFAULT_PROJECT_ID,
managed_project_id=DEFAULT_PROJECT_ID,
source="default",
)
def fetch_available_models(
access_token: str,
*,
project_id: str = "",
endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
) -> Dict[str, Any]:
body: Dict[str, Any] = {}
if project_id:
body["project"] = project_id
return _post_json(f"{endpoint}/v1internal:fetchAvailableModels", body, access_token)
def fetch_available_models_with_fallbacks(
access_token: str,
*,
project_id: str = "",
endpoints: Optional[Iterable[str]] = None,
) -> Dict[str, Any]:
last_err: Optional[Exception] = None
for endpoint in endpoints or ANTIGRAVITY_MODEL_ENDPOINTS:
try:
return fetch_available_models(
access_token,
project_id=project_id,
endpoint=endpoint,
)
except Exception as exc:
last_err = exc
logger.debug("Antigravity fetchAvailableModels failed on %s: %s", endpoint, exc)
if last_err:
raise last_err
return {}
def _model_id_from_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
for key in ("modelId", "model_id", "id", "name"):
candidate = str(value.get(key) or "").strip()
if candidate:
return candidate
return ""
def _ids_from_sort(sort: Dict[str, Any]) -> List[str]:
ids: List[str] = []
for key in ("modelIds", "model_ids", "models", "modelSorts"):
value = sort.get(key)
if isinstance(value, list):
for item in value:
mid = _model_id_from_value(item)
if mid:
ids.append(mid)
elif isinstance(value, dict):
mid = _model_id_from_value(value)
if mid:
ids.append(mid)
return ids
def _is_recommended_sort(sort: Dict[str, Any]) -> bool:
label = " ".join(
str(sort.get(key) or "")
for key in ("name", "displayName", "title", "category", "group")
).lower()
return "recommended" in label
def _raw_model_ids(payload: Dict[str, Any]) -> List[str]:
ids: List[str] = []
models = payload.get("models")
if isinstance(models, list):
for item in models:
mid = _model_id_from_value(item)
if mid:
ids.append(mid)
return ids
def filter_agent_model_ids(ids: Iterable[str]) -> List[str]:
seen: set[str] = set()
filtered: List[str] = []
raw = [str(mid).strip() for mid in ids if str(mid).strip()]
replacements = set(DEPRECATED_MODEL_REPLACEMENTS.values())
for mid in raw:
if mid in seen:
continue
if mid.startswith(("chat_", "tab_")):
continue
if mid in DEPRECATED_MODEL_REPLACEMENTS and DEPRECATED_MODEL_REPLACEMENTS[mid] in raw:
continue
if mid in replacements and mid in seen:
continue
seen.add(mid)
filtered.append(mid)
return filtered
def parse_agent_model_ids(payload: Dict[str, Any]) -> List[str]:
"""Return the user-facing Antigravity agent model list in display order."""
sorts = payload.get("agentModelSorts")
ordered: List[str] = []
if isinstance(sorts, list):
recommended = [s for s in sorts if isinstance(s, dict) and _is_recommended_sort(s)]
rest = [s for s in sorts if isinstance(s, dict) and not _is_recommended_sort(s)]
for sort in recommended + rest:
ordered.extend(_ids_from_sort(sort))
if not ordered:
default_id = str(payload.get("defaultAgentModelId") or "").strip()
if default_id:
ordered.append(default_id)
for mid in DEFAULT_AGENT_MODEL_IDS:
ordered.append(mid)
ordered.extend(_raw_model_ids(payload))
filtered = filter_agent_model_ids(ordered)
if filtered:
return filtered
return list(DEFAULT_AGENT_MODEL_IDS)

View File

@ -1,907 +0,0 @@
"""Google OAuth PKCE flow for the Antigravity (google-antigravity) provider.
Tokens are stored separately from the existing ``google-gemini-cli`` provider so
development and production credentials do not accidentally bleed across:
~/.hermes/auth/antigravity_oauth.json
The on-disk schema matches ``agent.google_oauth`` so the runtime resolver can
share the same refresh/project-id packing convention.
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import http.server
import json
import logging
import os
import re
import secrets
import shutil
import stat
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from hermes_constants import get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
ENV_CLIENT_ID = "HERMES_ANTIGRAVITY_CLIENT_ID"
ENV_CLIENT_SECRET = "HERMES_ANTIGRAVITY_CLIENT_SECRET"
ENV_CLI_PATH = "HERMES_ANTIGRAVITY_CLI_PATH"
# Public Antigravity CLI desktop OAuth client. Like Google's gemini-cli
# credentials (see agent/google_oauth.py), this is a DESKTOP OAuth client and
# its "secret" is not confidential — installed-app clients have no
# secret-keeping requirement (PKCE provides the security), and these creds are
# baked into every copy of the Antigravity CLI. Shipping them as a fallback
# lets users without `agy` installed authenticate directly. Split into parts
# with explicit comments per the convention in google_oauth.py.
_PUBLIC_CLIENT_ID_PROJECT_NUM = "1071006060591"
_PUBLIC_CLIENT_ID_HASH = "tmhssin2h21lcre235vtolojh4g403ep"
_PUBLIC_CLIENT_SECRET_SUFFIX = "K58FWR486LdLJ1mLB8sXC4z6qDAf"
_DEFAULT_CLIENT_ID = (
f"{_PUBLIC_CLIENT_ID_PROJECT_NUM}-{_PUBLIC_CLIENT_ID_HASH}"
".apps.googleusercontent.com"
)
_DEFAULT_CLIENT_SECRET = f"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}"
# Fallback project ID when Code Assist project discovery fails entirely.
DEFAULT_PROJECT_ID = "rising-fact-p41fc"
_CLIENT_ID_PATTERN = re.compile(
r"([0-9]{8,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com)"
)
_CLIENT_SECRET_PATTERN = re.compile(r"(GOCSPX-[A-Za-z0-9_-]{20,80})")
_DISCOVERY_MAX_FILE_BYTES = 25 * 1024 * 1024
_DISCOVERY_MAX_AGY_BINARY_BYTES = 220 * 1024 * 1024
_DISCOVERY_MAX_FILES = 600
_DISCOVERY_EXTENSIONS = {
"",
".cjs",
".exe",
".js",
".json",
".mjs",
".node",
".ts",
}
_DISCOVERY_SKIP_DIRS = {
".system_generated",
"brain",
"conversations",
"log",
"logs",
"scratch",
}
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"
OAUTH_SCOPES = (
"https://www.googleapis.com/auth/cloud-platform "
"https://www.googleapis.com/auth/userinfo.email "
"https://www.googleapis.com/auth/userinfo.profile "
"https://www.googleapis.com/auth/cclog "
"https://www.googleapis.com/auth/experimentsandconfigs"
)
DEFAULT_REDIRECT_PORT = 51121
REDIRECT_HOST = "localhost"
CALLBACK_PATH = "/oauth-callback"
REFRESH_SKEW_SECONDS = 60
TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0
CALLBACK_WAIT_SECONDS = 300
LOCK_TIMEOUT_SECONDS = 30.0
class AntigravityOAuthError(RuntimeError):
def __init__(self, message: str, *, code: str = "antigravity_oauth_error") -> None:
super().__init__(message)
self.code = code
def _credentials_path() -> Path:
return get_hermes_home() / "auth" / "antigravity_oauth.json"
def _lock_path() -> Path:
return _credentials_path().with_suffix(".json.lock")
_lock_state = threading.local()
@contextlib.contextmanager
def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):
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
except ImportError:
fcntl = None
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 Antigravity 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 Antigravity OAuth credentials lock at {lock_file_path}."
)
time.sleep(0.05)
except ImportError:
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
_discovered_creds_cache: Dict[str, Any] = {}
def _secret_candidates(raw: str) -> list[str]:
candidates: list[str] = []
for length in (35, 34, 36, 33, 37, 38, 39, 40, 41, 42):
if len(raw) >= length:
candidates.append(raw[:length])
candidates.append(raw)
return list(dict.fromkeys(candidates))
def _candidate_discovery_roots() -> list[Path]:
roots: list[Path] = []
explicit = (os.getenv(ENV_CLI_PATH) or "").strip()
if explicit:
roots.append(Path(explicit))
for command in ("agy", "agy.exe", "antigravity", "antigravity.exe"):
found = shutil.which(command)
if found:
roots.append(Path(found))
for env_key in ("LOCALAPPDATA", "APPDATA", "ProgramFiles", "ProgramFiles(x86)"):
base = os.getenv(env_key)
if not base:
continue
base_path = Path(base)
roots.extend((
base_path / "agy",
base_path / "agy" / "bin" / "agy.exe",
base_path / "Programs" / "Antigravity",
base_path / "Programs" / "Antigravity CLI",
base_path / "Google" / "Antigravity",
base_path / "Google" / "Antigravity CLI",
))
home = Path.home()
for root in (
home / ".gemini" / "antigravity-cli",
home / ".antigravitycli",
home / ".antigravity",
):
roots.append(root)
unique: list[Path] = []
seen: set[str] = set()
for root in roots:
try:
key = str(root.expanduser().resolve())
except OSError:
key = str(root.expanduser())
if key not in seen:
seen.add(key)
unique.append(root)
return unique
def _iter_discovery_files() -> list[Path]:
files: list[Path] = []
seen: set[str] = set()
def add(path: Path) -> None:
if len(files) >= _DISCOVERY_MAX_FILES:
return
if path.suffix.lower() not in _DISCOVERY_EXTENSIONS:
return
try:
stat_info = path.stat()
max_bytes = (
_DISCOVERY_MAX_AGY_BINARY_BYTES
if path.name.lower() in {"agy", "agy.exe", "antigravity", "antigravity.exe"}
else _DISCOVERY_MAX_FILE_BYTES
)
if not path.is_file() or stat_info.st_size > max_bytes:
return
key = str(path.resolve())
except OSError:
return
if key in seen:
return
seen.add(key)
files.append(path)
for root in _candidate_discovery_roots():
if len(files) >= _DISCOVERY_MAX_FILES:
break
try:
if root.is_file():
add(root)
continue
if not root.is_dir():
continue
except OSError:
continue
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if d not in _DISCOVERY_SKIP_DIRS and not d.startswith(".git")
]
for filename in filenames:
add(Path(dirpath) / filename)
if len(files) >= _DISCOVERY_MAX_FILES:
break
if len(files) >= _DISCOVERY_MAX_FILES:
break
return files
def _extract_client_credential_candidates_from_text(content: str) -> list[Tuple[str, str]]:
client_ids = list(dict.fromkeys(match.group(1) for match in _CLIENT_ID_PATTERN.finditer(content)))
secrets: list[str] = []
for match in _CLIENT_SECRET_PATTERN.finditer(content):
secrets.extend(_secret_candidates(match.group(1)))
secrets = list(dict.fromkeys(secrets))
return [(client_id, secret) for client_id in client_ids for secret in secrets]
def _discover_client_credentials() -> Tuple[str, str]:
if _discovered_creds_cache.get("resolved"):
return (
_discovered_creds_cache.get("client_id", ""),
_discovered_creds_cache.get("client_secret", ""),
)
for path in _iter_discovery_files():
try:
content = path.read_bytes().decode("utf-8", errors="ignore")
except OSError:
continue
candidates = _extract_client_credential_candidates_from_text(content)
if candidates:
client_id, client_secret = candidates[0]
_discovered_creds_cache.update({
"client_id": client_id,
"client_secret": client_secret,
"candidates": candidates,
"resolved": "1",
})
logger.info("Discovered Antigravity OAuth client credentials from %s", path)
return client_id, client_secret
_discovered_creds_cache["resolved"] = "1"
return "", ""
def _get_client_id() -> str:
env_val = (os.getenv(ENV_CLIENT_ID) or "").strip()
if env_val:
return env_val
discovered, _ = _discover_client_credentials()
if discovered:
return discovered
return _DEFAULT_CLIENT_ID
def _get_client_secret() -> str:
env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
if env_val:
return env_val
_, discovered = _discover_client_credentials()
if discovered:
return discovered
return _DEFAULT_CLIENT_SECRET
def _iter_client_credential_candidates() -> list[Tuple[str, str]]:
env_id = (os.getenv(ENV_CLIENT_ID) or "").strip()
env_secret = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
if env_id and env_secret:
return [(env_id, env_secret)]
_discover_client_credentials()
cached = _discovered_creds_cache.get("candidates")
candidates: list[Tuple[str, str]] = []
if isinstance(cached, list):
candidates = [
(str(client_id), str(client_secret))
for client_id, client_secret in cached
if client_id and client_secret
]
else:
client_id = str(_discovered_creds_cache.get("client_id") or "")
client_secret = str(_discovered_creds_cache.get("client_secret") or "")
if client_id and client_secret:
candidates = [(client_id, client_secret)]
# Always include the public baked-in default as a last-resort candidate so
# users without `agy` installed can still authenticate. De-dupe in case
# discovery already surfaced the same client.
default_pair = (_DEFAULT_CLIENT_ID, _DEFAULT_CLIENT_SECRET)
if default_pair not in candidates:
candidates.append(default_pair)
return candidates
def _require_client_id() -> str:
client_id = _get_client_id()
if not client_id:
raise AntigravityOAuthError(
"Antigravity OAuth client ID is not available. Install Antigravity CLI "
"so Hermes can discover its desktop OAuth client, set "
f"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and "
f"{ENV_CLIENT_SECRET} in ~/.hermes/.env.",
code="antigravity_oauth_client_id_missing",
)
return client_id
def _require_client_secret() -> str:
client_secret = _get_client_secret()
if not client_secret:
raise AntigravityOAuthError(
"Antigravity OAuth client secret is not available. Install Antigravity CLI "
"so Hermes can discover its desktop OAuth client, set "
f"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and "
f"{ENV_CLIENT_SECRET} in ~/.hermes/.env.",
code="antigravity_oauth_client_secret_missing",
)
return client_secret
def _require_client_credentials() -> Tuple[str, str]:
candidates = _iter_client_credential_candidates()
if not candidates:
_require_client_id()
_require_client_secret()
return candidates[0]
def _generate_pkce_pair() -> Tuple[str, str]:
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
@dataclass
class RefreshParts:
refresh_token: str
project_id: str = ""
managed_project_id: str = ""
@classmethod
def parse(cls, packed: str) -> "RefreshParts":
if not packed:
return cls(refresh_token="")
parts = packed.split("|", 2)
return cls(
refresh_token=parts[0],
project_id=parts[1] if len(parts) > 1 else "",
managed_project_id=parts[2] if len(parts) > 2 else "",
)
def format(self) -> str:
if not self.refresh_token:
return ""
if not self.project_id and not self.managed_project_id:
return self.refresh_token
return f"{self.refresh_token}|{self.project_id}|{self.managed_project_id}"
@dataclass
class AntigravityCredentials:
access_token: str
refresh_token: str
expires_ms: int
email: str = ""
project_id: str = ""
managed_project_id: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"refresh": RefreshParts(
refresh_token=self.refresh_token,
project_id=self.project_id,
managed_project_id=self.managed_project_id,
).format(),
"access": self.access_token,
"expires": int(self.expires_ms),
"email": self.email,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AntigravityCredentials":
parts = RefreshParts.parse(str(data.get("refresh", "") or ""))
return cls(
access_token=str(data.get("access", "") or ""),
refresh_token=parts.refresh_token,
expires_ms=int(data.get("expires", 0) or 0),
email=str(data.get("email", "") or ""),
project_id=parts.project_id,
managed_project_id=parts.managed_project_id,
)
def access_token_expired(self, skew_seconds: int = REFRESH_SKEW_SECONDS) -> bool:
if not self.access_token or not self.expires_ms:
return True
return (time.time() + max(0, skew_seconds)) * 1000 >= self.expires_ms
def load_credentials() -> Optional[AntigravityCredentials]:
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 Antigravity OAuth credentials at %s: %s", path, exc)
return None
if not isinstance(data, dict):
return None
creds = AntigravityCredentials.from_dict(data)
if not creds.access_token:
return None
return creds
def save_credentials(creds: AntigravityCredentials) -> Path:
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
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:
fd = os.open(
str(tmp_path),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
atomic_replace(tmp_path, path)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
return path
def clear_credentials() -> None:
path = _credentials_path()
with _credentials_lock():
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.warning("Failed to remove Antigravity OAuth credentials at %s: %s", path, exc)
def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:
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
code = "antigravity_oauth_token_http_error"
if "invalid_grant" in detail.lower():
code = "antigravity_oauth_invalid_grant"
elif "invalid_client" in detail.lower():
code = "antigravity_oauth_invalid_client"
raise AntigravityOAuthError(
f"Antigravity OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}",
code=code,
) from exc
except urllib.error.URLError as exc:
raise AntigravityOAuthError(
f"Antigravity OAuth token request failed: {exc}",
code="antigravity_oauth_token_network_error",
) from exc
def exchange_code(
code: str,
verifier: str,
redirect_uri: str,
*,
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
last_error: Optional[AntigravityOAuthError] = None
candidates = _iter_client_credential_candidates()
if not candidates:
candidates = [_require_client_credentials()]
for client_id, client_secret in candidates:
data = {
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
}
try:
return _post_form(TOKEN_ENDPOINT, data, timeout)
except AntigravityOAuthError as exc:
last_error = exc
if exc.code != "antigravity_oauth_invalid_client":
raise
if last_error is not None:
raise last_error
raise AntigravityOAuthError(
"Antigravity OAuth client credentials are unavailable.",
code="antigravity_oauth_client_missing",
)
def refresh_access_token(
refresh_token: str,
*,
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
if not refresh_token:
raise AntigravityOAuthError(
"Cannot refresh: refresh_token is empty. Re-run OAuth login.",
code="antigravity_oauth_refresh_token_missing",
)
last_error: Optional[AntigravityOAuthError] = None
candidates = _iter_client_credential_candidates()
if not candidates:
candidates = [_require_client_credentials()]
for client_id, client_secret in candidates:
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
}
try:
return _post_form(TOKEN_ENDPOINT, data, timeout)
except AntigravityOAuthError as exc:
last_error = exc
if exc.code not in {
"antigravity_oauth_invalid_client",
"antigravity_oauth_invalid_grant",
}:
raise
if last_error is not None:
raise last_error
raise AntigravityOAuthError(
"Antigravity OAuth client credentials are unavailable.",
code="antigravity_oauth_client_missing",
)
def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:
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("Antigravity userinfo fetch failed (non-fatal): %s", exc)
return ""
_refresh_inflight: Dict[str, threading.Event] = {}
_refresh_inflight_lock = threading.Lock()
def get_valid_access_token(*, force_refresh: bool = False) -> str:
creds = load_credentials()
if creds is None:
raise AntigravityOAuthError(
"No Antigravity OAuth credentials found. Run `hermes login --provider google-antigravity` first.",
code="antigravity_oauth_not_logged_in",
)
if not force_refresh and not creds.access_token_expired():
return creds.access_token
rt = creds.refresh_token
with _refresh_inflight_lock:
event = _refresh_inflight.get(rt)
if event is None:
event = threading.Event()
_refresh_inflight[rt] = event
owner = True
else:
owner = False
if not owner:
event.wait(timeout=LOCK_TIMEOUT_SECONDS)
fresh = load_credentials()
if fresh is not None and not fresh.access_token_expired():
return fresh.access_token
try:
try:
resp = refresh_access_token(rt)
except AntigravityOAuthError as exc:
if exc.code == "antigravity_oauth_invalid_grant":
clear_credentials()
raise
new_access = str(resp.get("access_token", "") or "").strip()
if not new_access:
raise AntigravityOAuthError(
"Refresh response did not include an access_token.",
code="antigravity_oauth_refresh_empty",
)
creds.access_token = new_access
creds.refresh_token = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token
expires_in = int(resp.get("expires_in", 0) or 0)
creds.expires_ms = int((time.time() + max(60, expires_in)) * 1000)
save_credentials(creds)
return creds.access_token
finally:
if owner:
with _refresh_inflight_lock:
_refresh_inflight.pop(rt, None)
event.set()
def update_project_ids(project_id: str = "", managed_project_id: str = "") -> None:
creds = load_credentials()
if creds is None:
return
if project_id:
creds.project_id = project_id
if managed_project_id:
creds.managed_project_id = managed_project_id
save_credentials(creds)
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
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
logger.debug("Antigravity 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]
handler_cls = type(self)
if state != self.expected_state:
handler_cls.captured_error = "OAuth state mismatch."
elif error:
handler_cls.captured_error = error
elif not code:
handler_cls.captured_error = "OAuth callback did not include a code."
else:
handler_cls.captured_code = code
ok = not handler_cls.captured_error
self.send_response(200 if ok else 400)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
msg = "Antigravity OAuth complete. You can return to Hermes." if ok else handler_cls.captured_error
self.wfile.write(f"<html><body><p>{msg}</p></body></html>".encode("utf-8"))
if handler_cls.ready is not None:
handler_cls.ready.set()
class _ReusableHTTPServer(http.server.HTTPServer):
allow_reuse_address = True
def resolve_project_id_from_env() -> str:
for key in ("HERMES_ANTIGRAVITY_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT_ID"):
value = (os.getenv(key) or "").strip()
if value:
return value
return ""
def start_oauth_flow(
*,
force_relogin: bool = False,
open_browser: bool = True,
port: int = DEFAULT_REDIRECT_PORT,
project_id: str = "",
) -> AntigravityCredentials:
if not force_relogin:
existing = load_credentials()
if existing and not existing.access_token_expired():
return existing
verifier, challenge = _generate_pkce_pair()
state = secrets.token_urlsafe(24)
client_id, _ = _require_client_credentials()
ready = threading.Event()
handler_cls = type("AntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {})
handler_cls.expected_state = state
handler_cls.captured_code = None
handler_cls.captured_error = None
handler_cls.ready = ready
try:
server = _ReusableHTTPServer((REDIRECT_HOST, int(port)), handler_cls)
except OSError:
server = _ReusableHTTPServer((REDIRECT_HOST, 0), handler_cls)
actual_port = int(server.server_address[1])
redirect_uri = f"http://{REDIRECT_HOST}:{actual_port}{CALLBACK_PATH}"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": OAUTH_SCOPES,
"access_type": "offline",
"prompt": "consent",
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
print("Open this URL to authorize Antigravity OAuth:")
print(auth_url)
if open_browser:
webbrowser.open(auth_url)
if not ready.wait(timeout=CALLBACK_WAIT_SECONDS):
raise AntigravityOAuthError(
"Timed out waiting for Antigravity OAuth callback.",
code="antigravity_oauth_callback_timeout",
)
if handler_cls.captured_error:
raise AntigravityOAuthError(
handler_cls.captured_error,
code="antigravity_oauth_callback_error",
)
code = handler_cls.captured_code or ""
token = exchange_code(code, verifier, redirect_uri)
finally:
server.shutdown()
server.server_close()
access_token = str(token.get("access_token", "") or "").strip()
refresh_token = str(token.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
raise AntigravityOAuthError(
"Antigravity OAuth response did not include both access_token and refresh_token.",
code="antigravity_oauth_missing_token",
)
expires_in = int(token.get("expires_in", 0) or 0)
creds = AntigravityCredentials(
access_token=access_token,
refresh_token=refresh_token,
expires_ms=int((time.time() + max(60, expires_in)) * 1000),
email=_fetch_user_email(access_token),
project_id=project_id,
)
save_credentials(creds)
return creds
def run_antigravity_oauth_login_pure() -> Dict[str, Any]:
creds = start_oauth_flow(
force_relogin=True,
project_id=resolve_project_id_from_env(),
)
return {
"access_token": creds.access_token,
"refresh_token": creds.refresh_token,
"expires_at_ms": creds.expires_ms,
"email": creds.email,
"project_id": creds.project_id,
}

View File

@ -1,915 +0,0 @@
"""OpenAI-compatible facade that talks to Google's Cloud Code Assist backend.
This adapter lets Hermes use the ``google-gemini-cli`` provider as if it were
a standard OpenAI-shaped chat completion endpoint, while the underlying HTTP
traffic goes to ``cloudcode-pa.googleapis.com/v1internal:{generateContent,
streamGenerateContent}`` with a Bearer access token obtained via OAuth PKCE.
Architecture
------------
- ``GeminiCloudCodeClient`` exposes ``.chat.completions.create(**kwargs)``
mirroring the subset of the OpenAI SDK that ``run_agent.py`` uses.
- Incoming OpenAI ``messages[]`` / ``tools[]`` / ``tool_choice`` are translated
to Gemini's native ``contents[]`` / ``tools[].functionDeclarations`` /
``toolConfig`` / ``systemInstruction`` shape.
- The request body is wrapped ``{project, model, user_prompt_id, request}``
per Code Assist API expectations.
- Responses (``candidates[].content.parts[]``) are converted back to
OpenAI ``choices[0].message`` shape with ``content`` + ``tool_calls``.
- Streaming uses SSE (``?alt=sse``) and yields OpenAI-shaped delta chunks.
Attribution
-----------
Translation semantics follow jenslys/opencode-gemini-auth (MIT) and the public
Gemini API docs. Request envelope shape
(``{project, model, user_prompt_id, request}``) is documented nowhere; it is
reverse-engineered from the opencode-gemini-auth and clawdbot implementations.
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from types import SimpleNamespace
from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent import google_oauth
from agent.gemini_schema import sanitize_gemini_tool_parameters
from agent.google_code_assist import (
CODE_ASSIST_ENDPOINT,
CodeAssistError,
ProjectContext,
resolve_project_context,
)
logger = logging.getLogger(__name__)
# =============================================================================
# Request translation: OpenAI → Gemini
# =============================================================================
_ROLE_MAP_OPENAI_TO_GEMINI = {
"user": "user",
"assistant": "model",
"system": "user", # handled separately via systemInstruction
"tool": "user", # functionResponse is wrapped in a user-role turn
"function": "user",
}
def _coerce_content_to_text(content: Any) -> str:
"""OpenAI content may be str or a list of parts; reduce to plain text."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
pieces: List[str] = []
for p in content:
if isinstance(p, str):
pieces.append(p)
elif isinstance(p, dict):
if p.get("type") == "text" and isinstance(p.get("text"), str):
pieces.append(p["text"])
# Multimodal (image_url, etc.) — stub for now; log and skip
elif p.get("type") in {"image_url", "input_audio"}:
logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type"))
return "\n".join(pieces)
return str(content)
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
"""OpenAI tool_call -> Gemini functionCall part."""
fn = tool_call.get("function") or {}
args_raw = fn.get("arguments", "")
try:
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
except json.JSONDecodeError:
args = {"_raw": args_raw}
if not isinstance(args, dict):
args = {"_value": args}
function_call = {
"name": fn.get("name") or "",
"args": args,
}
if tool_call.get("id"):
function_call["id"] = str(tool_call["id"])
return {
"functionCall": function_call,
# Sentinel signature — matches opencode-gemini-auth's approach.
# Without this, Code Assist rejects function calls that originated
# outside its own chain.
"thoughtSignature": "skip_thought_signature_validator",
}
def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
"""OpenAI tool-role message -> Gemini functionResponse part.
The function name isn't in the OpenAI tool message directly; it must be
passed via the assistant message that issued the call. For simplicity we
look up ``name`` on the message (OpenAI SDK copies it there) or on the
``tool_call_id`` cross-reference.
"""
name = str(message.get("name") or message.get("tool_call_id") or "tool")
content = _coerce_content_to_text(message.get("content"))
# Gemini expects the response as a dict under `response`. We wrap plain
# text in {"output": "..."}.
try:
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
except json.JSONDecodeError:
parsed = None
response = parsed if isinstance(parsed, dict) else {"output": content}
function_response = {
"name": name,
"response": response,
}
if message.get("tool_call_id"):
function_response["id"] = str(message["tool_call_id"])
return {"functionResponse": function_response}
def _build_gemini_contents(
messages: List[Dict[str, Any]],
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Convert OpenAI messages[] to Gemini contents[] + systemInstruction."""
system_text_parts: List[str] = []
contents: List[Dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "user")
if role == "system":
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
continue
# Tool result message — emit a user-role turn with functionResponse
if role == "tool" or role == "function":
contents.append({
"role": "user",
"parts": [_translate_tool_result_to_gemini(msg)],
})
continue
gemini_role = _ROLE_MAP_OPENAI_TO_GEMINI.get(role, "user")
parts: List[Dict[str, Any]] = []
text = _coerce_content_to_text(msg.get("content"))
if text:
parts.append({"text": text})
# Assistant messages can carry tool_calls
tool_calls = msg.get("tool_calls") or []
if isinstance(tool_calls, list):
for tc in tool_calls:
if isinstance(tc, dict):
parts.append(_translate_tool_call_to_gemini(tc))
if not parts:
# Gemini rejects empty parts; skip the turn entirely
continue
contents.append({"role": gemini_role, "parts": parts})
system_instruction: Optional[Dict[str, Any]] = None
joined_system = "\n".join(p for p in system_text_parts if p).strip()
if joined_system:
system_instruction = {
"role": "system",
"parts": [{"text": joined_system}],
}
return contents, system_instruction
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
"""OpenAI tools[] -> Gemini tools[].functionDeclarations[]."""
if not isinstance(tools, list) or not tools:
return []
declarations: List[Dict[str, Any]] = []
for t in tools:
if not isinstance(t, dict):
continue
fn = t.get("function") or {}
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not name:
continue
decl = {"name": str(name)}
if fn.get("description"):
decl["description"] = str(fn["description"])
params = fn.get("parameters")
if isinstance(params, dict):
decl["parameters"] = sanitize_gemini_tool_parameters(params)
declarations.append(decl)
if not declarations:
return []
return [{"functionDeclarations": declarations}]
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
"""OpenAI tool_choice -> Gemini toolConfig.functionCallingConfig."""
if tool_choice is None:
return None
if isinstance(tool_choice, str):
if tool_choice == "auto":
return {"functionCallingConfig": {"mode": "AUTO"}}
if tool_choice == "required":
return {"functionCallingConfig": {"mode": "ANY"}}
if tool_choice == "none":
return {"functionCallingConfig": {"mode": "NONE"}}
if isinstance(tool_choice, dict):
fn = tool_choice.get("function") or {}
name = fn.get("name")
if name:
return {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": [str(name)],
},
}
return None
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
"""Accept thinkingBudget / thinkingLevel / includeThoughts (+ snake_case)."""
if not isinstance(config, dict) or not config:
return None
budget = config.get("thinkingBudget", config.get("thinking_budget"))
level = config.get("thinkingLevel", config.get("thinking_level"))
include = config.get("includeThoughts", config.get("include_thoughts"))
normalized: Dict[str, Any] = {}
if isinstance(budget, (int, float)):
normalized["thinkingBudget"] = int(budget)
if isinstance(level, str) and level.strip():
normalized["thinkingLevel"] = level.strip().lower()
if isinstance(include, bool):
normalized["includeThoughts"] = include
return normalized or None
def build_gemini_request(
*,
messages: List[Dict[str, Any]],
tools: Any = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stop: Any = None,
thinking_config: Any = None,
) -> Dict[str, Any]:
"""Build the inner Gemini request body (goes inside ``request`` wrapper)."""
contents, system_instruction = _build_gemini_contents(messages)
body: Dict[str, Any] = {"contents": contents}
if system_instruction is not None:
body["systemInstruction"] = system_instruction
gemini_tools = _translate_tools_to_gemini(tools)
if gemini_tools:
body["tools"] = gemini_tools
tool_cfg = _translate_tool_choice_to_gemini(tool_choice)
if tool_cfg is not None:
body["toolConfig"] = tool_cfg
generation_config: Dict[str, Any] = {}
if isinstance(temperature, (int, float)):
generation_config["temperature"] = float(temperature)
if isinstance(max_tokens, int) and max_tokens > 0:
generation_config["maxOutputTokens"] = max_tokens
if isinstance(top_p, (int, float)):
generation_config["topP"] = float(top_p)
if isinstance(stop, str) and stop:
generation_config["stopSequences"] = [stop]
elif isinstance(stop, list) and stop:
generation_config["stopSequences"] = [str(s) for s in stop if s]
normalized_thinking = _normalize_thinking_config(thinking_config)
if normalized_thinking:
generation_config["thinkingConfig"] = normalized_thinking
if generation_config:
body["generationConfig"] = generation_config
return body
def wrap_code_assist_request(
*,
project_id: str,
model: str,
inner_request: Dict[str, Any],
user_prompt_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Wrap the inner Gemini request in the Code Assist envelope."""
return {
"project": project_id,
"model": model,
"user_prompt_id": user_prompt_id or str(uuid.uuid4()),
"request": inner_request,
}
# =============================================================================
# Response translation: Gemini → OpenAI
# =============================================================================
def _translate_gemini_response(
resp: Dict[str, Any],
model: str,
) -> SimpleNamespace:
"""Non-streaming Gemini response -> OpenAI-shaped SimpleNamespace.
Code Assist wraps the actual Gemini response inside ``response``, so we
unwrap it first if present.
"""
inner = resp.get("response") if isinstance(resp.get("response"), dict) else resp
candidates = inner.get("candidates") or []
if not isinstance(candidates, list) or not candidates:
return _empty_response(model)
cand = candidates[0]
content_obj = cand.get("content") if isinstance(cand, dict) else {}
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
text_pieces: List[str] = []
reasoning_pieces: List[str] = []
tool_calls: List[SimpleNamespace] = []
for i, part in enumerate(parts or []):
if not isinstance(part, dict):
continue
# Thought parts are model's internal reasoning — surface as reasoning,
# don't mix into content.
if part.get("thought") is True:
if isinstance(part.get("text"), str):
reasoning_pieces.append(part["text"])
continue
if isinstance(part.get("text"), str):
text_pieces.append(part["text"])
continue
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
try:
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
except (TypeError, ValueError):
args_str = "{}"
call_id = str(fc.get("id") or "").strip() or f"call_{uuid.uuid4().hex[:12]}"
tool_calls.append(SimpleNamespace(
id=call_id,
type="function",
index=i,
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
))
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(
str(cand.get("finishReason") or "")
)
usage_meta = inner.get("usageMetadata") or {}
usage = SimpleNamespace(
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
prompt_tokens_details=SimpleNamespace(
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
),
)
message = SimpleNamespace(
role="assistant",
content="".join(text_pieces) if text_pieces else None,
tool_calls=tool_calls or None,
reasoning="".join(reasoning_pieces) or None,
reasoning_content="".join(reasoning_pieces) or None,
reasoning_details=None,
)
choice = SimpleNamespace(
index=0,
message=message,
finish_reason=finish_reason,
)
return SimpleNamespace(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion",
created=int(time.time()),
model=model,
choices=[choice],
usage=usage,
)
def _empty_response(model: str) -> SimpleNamespace:
message = SimpleNamespace(
role="assistant", content="", tool_calls=None,
reasoning=None, reasoning_content=None, reasoning_details=None,
)
choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
usage = SimpleNamespace(
prompt_tokens=0, completion_tokens=0, total_tokens=0,
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
)
return SimpleNamespace(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion",
created=int(time.time()),
model=model,
choices=[choice],
usage=usage,
)
def _map_gemini_finish_reason(reason: str) -> str:
mapping = {
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
"OTHER": "stop",
}
return mapping.get(reason.upper(), "stop")
# =============================================================================
# Streaming SSE iterator
# =============================================================================
class _GeminiStreamChunk(SimpleNamespace):
"""Mimics an OpenAI ChatCompletionChunk with .choices[0].delta."""
pass
def _make_stream_chunk(
*,
model: str,
content: str = "",
tool_call_delta: Optional[Dict[str, Any]] = None,
finish_reason: Optional[str] = None,
reasoning: str = "",
) -> _GeminiStreamChunk:
delta_kwargs: Dict[str, Any] = {
"role": "assistant",
"content": None,
"tool_calls": None,
"reasoning": None,
"reasoning_content": None,
}
if content:
delta_kwargs["content"] = content
if tool_call_delta is not None:
delta_kwargs["tool_calls"] = [SimpleNamespace(
index=tool_call_delta.get("index", 0),
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
type="function",
function=SimpleNamespace(
name=tool_call_delta.get("name") or "",
arguments=tool_call_delta.get("arguments") or "",
),
)]
if reasoning:
delta_kwargs["reasoning"] = reasoning
delta_kwargs["reasoning_content"] = reasoning
delta = SimpleNamespace(**delta_kwargs)
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
return _GeminiStreamChunk(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion.chunk",
created=int(time.time()),
model=model,
choices=[choice],
usage=None,
)
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
"""Parse Server-Sent Events from an httpx streaming response."""
buffer = ""
for chunk in response.iter_text():
if not chunk:
continue
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.rstrip("\r")
if not line:
continue
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON SSE line: %s", data[:200])
def _translate_stream_event(
event: Dict[str, Any],
model: str,
tool_call_counter: List[int],
) -> List[_GeminiStreamChunk]:
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
``tool_call_counter`` is a single-element list used as a mutable counter
across events in the same stream. Each ``functionCall`` part gets a
fresh, unique OpenAI ``index`` keying by function name would collide
whenever the model issues parallel calls to the same tool (e.g. reading
three files in one turn).
"""
inner = event.get("response") if isinstance(event.get("response"), dict) else event
candidates = inner.get("candidates") or []
if not candidates:
return []
cand = candidates[0]
if not isinstance(cand, dict):
return []
chunks: List[_GeminiStreamChunk] = []
content = cand.get("content") or {}
parts = content.get("parts") if isinstance(content, dict) else []
for part in parts or []:
if not isinstance(part, dict):
continue
if part.get("thought") is True and isinstance(part.get("text"), str):
chunks.append(_make_stream_chunk(
model=model, reasoning=part["text"],
))
continue
if isinstance(part.get("text"), str) and part["text"]:
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
name = str(fc["name"])
idx = tool_call_counter[0]
tool_call_counter[0] += 1
try:
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
except (TypeError, ValueError):
args_str = "{}"
chunks.append(_make_stream_chunk(
model=model,
tool_call_delta={
"index": idx,
"id": str(fc.get("id") or "").strip(),
"name": name,
"arguments": args_str,
},
))
finish_reason_raw = str(cand.get("finishReason") or "")
if finish_reason_raw:
mapped = _map_gemini_finish_reason(finish_reason_raw)
if tool_call_counter[0] > 0:
mapped = "tool_calls"
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
return chunks
# =============================================================================
# GeminiCloudCodeClient — OpenAI-compatible facade
# =============================================================================
MARKER_BASE_URL = "cloudcode-pa://google"
class _GeminiChatCompletions:
def __init__(self, client: "GeminiCloudCodeClient"):
self._client = client
def create(self, **kwargs: Any) -> Any:
return self._client._create_chat_completion(**kwargs)
class _GeminiChatNamespace:
def __init__(self, client: "GeminiCloudCodeClient"):
self.completions = _GeminiChatCompletions(client)
class GeminiCloudCodeClient:
"""Minimal OpenAI-SDK-compatible facade over Code Assist v1internal."""
def __init__(
self,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
default_headers: Optional[Dict[str, str]] = None,
project_id: str = "",
**_: Any,
):
# `api_key` here is a dummy — real auth is the OAuth access token
# fetched on every call via agent.google_oauth.get_valid_access_token().
# We accept the kwarg for openai.OpenAI interface parity.
self.api_key = api_key or "google-oauth"
self.base_url = base_url or MARKER_BASE_URL
self._default_headers = dict(default_headers or {})
self._configured_project_id = project_id
self._project_context: Optional[ProjectContext] = None
self._project_context_lock = False # simple single-thread guard
self.chat = _GeminiChatNamespace(self)
self.is_closed = False
self._http = httpx.Client(timeout=httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))
def close(self) -> None:
self.is_closed = True
try:
self._http.close()
except Exception:
pass
# Implement the OpenAI SDK's context-manager-ish closure check
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
"""Lazily resolve and cache the project context for this client."""
if self._project_context is not None:
return self._project_context
env_project = google_oauth.resolve_project_id_from_env()
creds = google_oauth.load_credentials()
stored_project = creds.project_id if creds else ""
# Prefer what's already baked into the creds
if stored_project:
self._project_context = ProjectContext(
project_id=stored_project,
managed_project_id=creds.managed_project_id if creds else "",
tier_id="",
source="stored",
)
return self._project_context
ctx = resolve_project_context(
access_token,
configured_project_id=self._configured_project_id,
env_project_id=env_project,
user_agent_model=model,
)
# Persist discovered project back to the creds file so the next
# session doesn't re-run the discovery.
if ctx.project_id or ctx.managed_project_id:
google_oauth.update_project_ids(
project_id=ctx.project_id,
managed_project_id=ctx.managed_project_id,
)
self._project_context = ctx
return ctx
def _create_chat_completion(
self,
*,
model: str = "gemini-2.5-flash",
messages: Optional[List[Dict[str, Any]]] = None,
stream: bool = False,
tools: Any = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stop: Any = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Any = None,
**_: Any,
) -> Any:
access_token = google_oauth.get_valid_access_token()
ctx = self._ensure_project_context(access_token, model)
thinking_config = None
if isinstance(extra_body, dict):
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
inner = build_gemini_request(
messages=messages or [],
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
stop=stop,
thinking_config=thinking_config,
)
wrapped = wrap_code_assist_request(
project_id=ctx.project_id,
model=model,
inner_request=inner,
)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
"User-Agent": "hermes-agent (gemini-cli-compat)",
"X-Goog-Api-Client": "gl-python/hermes",
"x-activity-request-id": str(uuid.uuid4()),
}
headers.update(self._default_headers)
if stream:
return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
response = self._http.post(url, json=wrapped, headers=headers)
if response.status_code != 200:
raise _gemini_http_error(response)
try:
payload = response.json()
except ValueError as exc:
raise CodeAssistError(
f"Invalid JSON from Code Assist: {exc}",
code="code_assist_invalid_json",
) from exc
return _translate_gemini_response(payload, model=model)
def _stream_completion(
self,
*,
model: str,
wrapped: Dict[str, Any],
headers: Dict[str, str],
) -> Iterator[_GeminiStreamChunk]:
"""Generator that yields OpenAI-shaped streaming chunks."""
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse"
stream_headers = dict(headers)
stream_headers["Accept"] = "text/event-stream"
def _generator() -> Iterator[_GeminiStreamChunk]:
try:
with self._http.stream("POST", url, json=wrapped, headers=stream_headers) as response:
if response.status_code != 200:
# Materialize error body for better diagnostics
response.read()
raise _gemini_http_error(response)
tool_call_counter: List[int] = [0]
for event in _iter_sse_events(response):
for chunk in _translate_stream_event(event, model, tool_call_counter):
yield chunk
except httpx.HTTPError as exc:
raise CodeAssistError(
f"Streaming request failed: {exc}",
code="code_assist_stream_error",
) from exc
return _generator()
def _gemini_http_error(response: httpx.Response) -> CodeAssistError:
"""Translate an httpx response into a CodeAssistError with rich metadata.
Parses Google's error envelope (``{"error": {"code", "message", "status",
"details": [...]}}``) so the agent's error classifier can reason about
the failure ``status_code`` enables the rate_limit / auth classification
paths, and ``response`` lets the main loop honor ``Retry-After`` just
like it does for OpenAI SDK exceptions.
Also lifts a few recognizable Google conditions into human-readable
messages so the user sees something better than a 500-char JSON dump:
MODEL_CAPACITY_EXHAUSTED "Gemini model capacity exhausted for
<model>. This is a Google-side throttle..."
RESOURCE_EXHAUSTED w/o reason quota-style message
404 "Model <name> not found at cloudcode-pa..."
"""
status = response.status_code
# Parse the body once, surviving any weird encodings.
body_text = ""
body_json: Dict[str, Any] = {}
try:
body_text = response.text
except Exception:
body_text = ""
if body_text:
try:
parsed = json.loads(body_text)
if isinstance(parsed, dict):
body_json = parsed
except (ValueError, TypeError):
body_json = {}
# Dig into Google's error envelope. Shape is:
# {"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED",
# "details": [{"@type": ".../ErrorInfo", "reason": "MODEL_CAPACITY_EXHAUSTED",
# "metadata": {...}},
# {"@type": ".../RetryInfo", "retryDelay": "30s"}]}}
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
if not isinstance(err_obj, dict):
err_obj = {}
err_status = str(err_obj.get("status") or "").strip()
err_message = str(err_obj.get("message") or "").strip()
_raw_details = err_obj.get("details")
err_details_list = _raw_details if isinstance(_raw_details, list) else []
# Extract google.rpc.ErrorInfo reason + metadata. There may be more
# than one ErrorInfo (rare), so we pick the first one with a reason.
error_reason = ""
error_metadata: Dict[str, Any] = {}
retry_delay_seconds: Optional[float] = None
for detail in err_details_list:
if not isinstance(detail, dict):
continue
type_url = str(detail.get("@type") or "")
if not error_reason and type_url.endswith("/google.rpc.ErrorInfo"):
reason = detail.get("reason")
if isinstance(reason, str) and reason:
error_reason = reason
md = detail.get("metadata")
if isinstance(md, dict):
error_metadata = md
elif retry_delay_seconds is None and type_url.endswith("/google.rpc.RetryInfo"):
# retryDelay is a google.protobuf.Duration string like "30s" or "1.5s".
delay_raw = detail.get("retryDelay")
if isinstance(delay_raw, str) and delay_raw.endswith("s"):
try:
retry_delay_seconds = float(delay_raw[:-1])
except ValueError:
pass
elif isinstance(delay_raw, (int, float)):
retry_delay_seconds = float(delay_raw)
# Fall back to the Retry-After header if the body didn't include RetryInfo.
if retry_delay_seconds is None:
try:
header_val = response.headers.get("Retry-After") or response.headers.get("retry-after")
except Exception:
header_val = None
if header_val:
try:
retry_delay_seconds = float(header_val)
except (TypeError, ValueError):
retry_delay_seconds = None
# Classify the error code. ``code_assist_rate_limited`` stays the default
# for 429s; a more specific reason tag helps downstream callers (e.g. tests,
# logs) without changing the rate_limit classification path.
code = f"code_assist_http_{status}"
if status == 401:
code = "code_assist_unauthorized"
elif status == 429:
code = "code_assist_rate_limited"
if error_reason == "MODEL_CAPACITY_EXHAUSTED":
code = "code_assist_capacity_exhausted"
# Build a human-readable message. Keep the status + a raw-body tail for
# debugging, but lead with a friendlier summary when we recognize the
# Google signal.
model_hint = ""
if isinstance(error_metadata, dict):
model_hint = str(error_metadata.get("model") or error_metadata.get("modelId") or "").strip()
if status == 429 and error_reason == "MODEL_CAPACITY_EXHAUSTED":
target = model_hint or "this Gemini model"
message = (
f"Gemini capacity exhausted for {target} (Google-side throttle, "
f"not a Hermes issue). Try a different Gemini model or set a "
f"fallback_providers entry to a non-Gemini provider."
)
if retry_delay_seconds is not None:
message += f" Google suggests retrying in {retry_delay_seconds:g}s."
elif status == 429 and err_status == "RESOURCE_EXHAUSTED":
message = (
f"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). "
f"Check /gquota for remaining daily requests."
)
if retry_delay_seconds is not None:
message += f" Retry suggested in {retry_delay_seconds:g}s."
elif status == 404:
# Google returns 404 when a model has been retired or renamed.
target = model_hint or (err_message or "model")
message = (
f"Code Assist 404: {target} is not available at "
f"cloudcode-pa.googleapis.com. It may have been renamed or "
f"retired. Check hermes_cli/models.py for the current list."
)
elif err_message:
# Generic fallback with the parsed message.
message = f"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}"
else:
# Last-ditch fallback — raw body snippet.
message = f"Code Assist returned HTTP {status}: {body_text[:500]}"
return CodeAssistError(
message,
code=code,
status_code=status,
response=response,
retry_after=retry_delay_seconds,
details={
"status": err_status,
"reason": error_reason,
"metadata": error_metadata,
"message": err_message,
},
)

View File

@ -1,451 +0,0 @@
"""Google Code Assist API client — project discovery, onboarding, quota.
The Code Assist API powers Google's official gemini-cli. It sits at
``cloudcode-pa.googleapis.com`` and provides:
- Free tier access (generous daily quota) for personal Google accounts
- Paid tier access via GCP projects with billing / Workspace / Standard / Enterprise
This module handles the control-plane dance needed before inference:
1. ``load_code_assist()`` probe the user's account to learn what tier they're on
and whether a ``cloudaicompanionProject`` is already assigned.
2. ``onboard_user()`` if the user hasn't been onboarded yet (new account, fresh
free tier, etc.), call this with the chosen tier + project id. Supports LRO
polling for slow provisioning.
3. ``retrieve_user_quota()`` fetch the ``buckets[]`` array showing remaining
quota per model, used by the ``/gquota`` slash command.
VPC-SC handling: enterprise accounts under a VPC Service Controls perimeter
will get ``SECURITY_POLICY_VIOLATED`` on ``load_code_assist``. We catch this
and force the account to ``standard-tier`` so the call chain still succeeds.
Derived from opencode-gemini-auth (MIT) and clawdbot/extensions/google. The
request/response shapes are specific to Google's internal Code Assist API,
documented nowhere public we copy them from the reference implementations.
"""
from __future__ import annotations
import json
import logging
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Constants
# =============================================================================
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
# Fallback endpoints tried when prod returns an error during project discovery
FALLBACK_ENDPOINTS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
]
# Tier identifiers that Google's API uses
FREE_TIER_ID = "free-tier"
LEGACY_TIER_ID = "legacy-tier"
STANDARD_TIER_ID = "standard-tier"
# Default HTTP headers matching gemini-cli's fingerprint.
# Google may reject unrecognized User-Agents on these internal endpoints.
_GEMINI_CLI_USER_AGENT = "google-api-nodejs-client/9.15.1 (gzip)"
_X_GOOG_API_CLIENT = "gl-node/24.0.0"
_DEFAULT_REQUEST_TIMEOUT = 30.0
_ONBOARDING_POLL_ATTEMPTS = 12
_ONBOARDING_POLL_INTERVAL_SECONDS = 5.0
class CodeAssistError(RuntimeError):
"""Exception raised by the Code Assist (``cloudcode-pa``) integration.
Carries HTTP status / response / retry-after metadata so the agent's
``error_classifier._extract_status_code`` and the main loop's Retry-After
handling (which walks ``error.response.headers``) pick up the right
signals. Without these, 429s from the OAuth path look like opaque
``RuntimeError`` and skip the rate-limit path.
"""
def __init__(
self,
message: str,
*,
code: str = "code_assist_error",
status_code: Optional[int] = None,
response: Any = None,
retry_after: Optional[float] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
# ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``
# so a 429 from Code Assist classifies as FailoverReason.rate_limit and
# triggers the main loop's fallback_providers chain the same way SDK
# errors do.
self.status_code = status_code
# ``response`` is the underlying ``httpx.Response`` (or a shim with a
# ``.headers`` mapping and ``.json()`` method). The main loop reads
# ``error.response.headers["Retry-After"]`` to honor Google's retry
# hints when the backend throttles us.
self.response = response
# Parsed ``Retry-After`` seconds (kept separately for convenience —
# Google returns retry hints in both the header and the error body's
# ``google.rpc.RetryInfo`` details, and we pick whichever we found).
self.retry_after = retry_after
# Parsed structured error details from the Google error envelope
# (e.g. ``{"reason": "MODEL_CAPACITY_EXHAUSTED", "status": "RESOURCE_EXHAUSTED"}``).
# Useful for logging and for tests that want to assert on specifics.
self.details = details or {}
class ProjectIdRequiredError(CodeAssistError):
def __init__(self, message: str = "GCP project id required for this tier") -> None:
super().__init__(message, code="code_assist_project_id_required")
# =============================================================================
# HTTP primitive (auth via Bearer token passed per-call)
# =============================================================================
def _build_headers(access_token: str, *, user_agent_model: str = "") -> Dict[str, str]:
ua = _GEMINI_CLI_USER_AGENT
if user_agent_model:
ua = f"{ua} model/{user_agent_model}"
return {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
"User-Agent": ua,
"X-Goog-Api-Client": _X_GOOG_API_CLIENT,
"x-activity-request-id": str(uuid.uuid4()),
}
def _client_metadata() -> Dict[str, str]:
"""Match Google's gemini-cli exactly — unrecognized metadata may be rejected."""
return {
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
}
def _post_json(
url: str,
body: Dict[str, Any],
access_token: str,
*,
timeout: float = _DEFAULT_REQUEST_TIMEOUT,
user_agent_model: str = "",
) -> Dict[str, Any]:
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url, data=data, method="POST",
headers=_build_headers(access_token, user_agent_model=user_agent_model),
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", errors="replace")
except Exception:
pass
# Special case: VPC-SC violation should be distinguishable
if _is_vpc_sc_violation(detail):
raise CodeAssistError(
f"VPC-SC policy violation: {detail}",
code="code_assist_vpc_sc",
) from exc
raise CodeAssistError(
f"Code Assist HTTP {exc.code}: {detail or exc.reason}",
code=f"code_assist_http_{exc.code}",
) from exc
except urllib.error.URLError as exc:
raise CodeAssistError(
f"Code Assist request failed: {exc}",
code="code_assist_network_error",
) from exc
def _is_vpc_sc_violation(body: str) -> bool:
"""Detect a VPC Service Controls violation from a response body."""
if not body:
return False
try:
parsed = json.loads(body)
except (json.JSONDecodeError, ValueError):
return "SECURITY_POLICY_VIOLATED" in body
# Walk the nested error structure Google uses
error = parsed.get("error") if isinstance(parsed, dict) else None
if not isinstance(error, dict):
return False
details = error.get("details") or []
if isinstance(details, list):
for item in details:
if isinstance(item, dict):
reason = item.get("reason") or ""
if reason == "SECURITY_POLICY_VIOLATED":
return True
msg = str(error.get("message", ""))
return "SECURITY_POLICY_VIOLATED" in msg
# =============================================================================
# load_code_assist — discovers current tier + assigned project
# =============================================================================
@dataclass
class CodeAssistProjectInfo:
"""Result from ``load_code_assist``."""
current_tier_id: str = ""
cloudaicompanion_project: str = "" # Google-managed project (free tier)
allowed_tiers: List[str] = field(default_factory=list)
raw: Dict[str, Any] = field(default_factory=dict)
def load_code_assist(
access_token: str,
*,
project_id: str = "",
user_agent_model: str = "",
) -> CodeAssistProjectInfo:
"""Call ``POST /v1internal:loadCodeAssist`` with prod → sandbox fallback.
Returns whatever tier + project info Google reports. On VPC-SC violations,
returns a synthetic ``standard-tier`` result so the chain can continue.
"""
body: Dict[str, Any] = {
"metadata": {
"duetProject": project_id,
**_client_metadata(),
},
}
if project_id:
body["cloudaicompanionProject"] = project_id
endpoints = [CODE_ASSIST_ENDPOINT] + FALLBACK_ENDPOINTS
last_err: Optional[Exception] = None
for endpoint in endpoints:
url = f"{endpoint}/v1internal:loadCodeAssist"
try:
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
return _parse_load_response(resp)
except CodeAssistError as exc:
if exc.code == "code_assist_vpc_sc":
logger.info("VPC-SC violation on %s — defaulting to standard-tier", endpoint)
return CodeAssistProjectInfo(
current_tier_id=STANDARD_TIER_ID,
cloudaicompanion_project=project_id,
)
last_err = exc
logger.warning("loadCodeAssist failed on %s: %s", endpoint, exc)
continue
if last_err:
raise last_err
return CodeAssistProjectInfo()
def _parse_load_response(resp: Dict[str, Any]) -> CodeAssistProjectInfo:
current_tier = resp.get("currentTier") or {}
tier_id = str(current_tier.get("id") or "") if isinstance(current_tier, dict) else ""
project = str(resp.get("cloudaicompanionProject") or "")
allowed = resp.get("allowedTiers") or []
allowed_ids: List[str] = []
if isinstance(allowed, list):
for t in allowed:
if isinstance(t, dict):
tid = str(t.get("id") or "")
if tid:
allowed_ids.append(tid)
return CodeAssistProjectInfo(
current_tier_id=tier_id,
cloudaicompanion_project=project,
allowed_tiers=allowed_ids,
raw=resp,
)
# =============================================================================
# onboard_user — provisions a new user on a tier (with LRO polling)
# =============================================================================
def onboard_user(
access_token: str,
*,
tier_id: str,
project_id: str = "",
user_agent_model: str = "",
) -> Dict[str, Any]:
"""Call ``POST /v1internal:onboardUser`` to provision the user.
For paid tiers, ``project_id`` is REQUIRED (raises ProjectIdRequiredError).
For free tiers, ``project_id`` is optional Google will assign one.
Returns the final operation response. Polls ``/v1internal/<name>`` for up
to ``_ONBOARDING_POLL_ATTEMPTS`` × ``_ONBOARDING_POLL_INTERVAL_SECONDS``
(default: 12 × 5s = 1 min).
"""
if tier_id != FREE_TIER_ID and tier_id != LEGACY_TIER_ID and not project_id:
raise ProjectIdRequiredError(
f"Tier {tier_id!r} requires a GCP project id. "
"Set HERMES_GEMINI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
)
body: Dict[str, Any] = {
"tierId": tier_id,
"metadata": _client_metadata(),
}
if project_id:
body["cloudaicompanionProject"] = project_id
endpoint = CODE_ASSIST_ENDPOINT
url = f"{endpoint}/v1internal:onboardUser"
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
# Poll if LRO (long-running operation)
if not resp.get("done"):
op_name = resp.get("name", "")
if not op_name:
return resp
for attempt in range(_ONBOARDING_POLL_ATTEMPTS):
time.sleep(_ONBOARDING_POLL_INTERVAL_SECONDS)
poll_url = f"{endpoint}/v1internal/{op_name}"
try:
poll_resp = _post_json(poll_url, {}, access_token, user_agent_model=user_agent_model)
except CodeAssistError as exc:
logger.warning("Onboarding poll attempt %d failed: %s", attempt + 1, exc)
continue
if poll_resp.get("done"):
return poll_resp
logger.warning("Onboarding did not complete within %d attempts", _ONBOARDING_POLL_ATTEMPTS)
return resp
# =============================================================================
# retrieve_user_quota — for /gquota
# =============================================================================
@dataclass
class QuotaBucket:
model_id: str
token_type: str = ""
remaining_fraction: float = 0.0
reset_time_iso: str = ""
raw: Dict[str, Any] = field(default_factory=dict)
def retrieve_user_quota(
access_token: str,
*,
project_id: str = "",
user_agent_model: str = "",
) -> List[QuotaBucket]:
"""Call ``POST /v1internal:retrieveUserQuota`` and parse ``buckets[]``."""
body: Dict[str, Any] = {}
if project_id:
body["project"] = project_id
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota"
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
raw_buckets = resp.get("buckets") or []
buckets: List[QuotaBucket] = []
if not isinstance(raw_buckets, list):
return buckets
for b in raw_buckets:
if not isinstance(b, dict):
continue
buckets.append(QuotaBucket(
model_id=str(b.get("modelId") or ""),
token_type=str(b.get("tokenType") or ""),
remaining_fraction=float(b.get("remainingFraction") or 0.0),
reset_time_iso=str(b.get("resetTime") or ""),
raw=b,
))
return buckets
# =============================================================================
# Project context resolution
# =============================================================================
@dataclass
class ProjectContext:
"""Resolved state for a given OAuth session."""
project_id: str = "" # effective project id sent on requests
managed_project_id: str = "" # Google-assigned project (free tier)
tier_id: str = ""
source: str = "" # "env", "config", "discovered", "onboarded"
def resolve_project_context(
access_token: str,
*,
configured_project_id: str = "",
env_project_id: str = "",
user_agent_model: str = "",
) -> ProjectContext:
"""Figure out what project id + tier to use for requests.
Priority:
1. If configured_project_id or env_project_id is set, use that directly
and short-circuit (no discovery needed).
2. Otherwise call loadCodeAssist to see what Google says.
3. If no tier assigned yet, onboard the user (free tier default).
"""
# Short-circuit: caller provided a project id
if configured_project_id:
return ProjectContext(
project_id=configured_project_id,
tier_id=STANDARD_TIER_ID, # assume paid since they specified one
source="config",
)
if env_project_id:
return ProjectContext(
project_id=env_project_id,
tier_id=STANDARD_TIER_ID,
source="env",
)
# Discover via loadCodeAssist
info = load_code_assist(access_token, user_agent_model=user_agent_model)
effective_project = info.cloudaicompanion_project
tier = info.current_tier_id
if not tier:
# User hasn't been onboarded — provision them on free tier
onboard_resp = onboard_user(
access_token,
tier_id=FREE_TIER_ID,
project_id="",
user_agent_model=user_agent_model,
)
# Re-parse from the onboard response
response_body = onboard_resp.get("response") or {}
if isinstance(response_body, dict):
effective_project = (
effective_project
or str(response_body.get("cloudaicompanionProject") or "")
)
tier = FREE_TIER_ID
source = "onboarded"
else:
source = "discovered"
return ProjectContext(
project_id=effective_project,
managed_project_id=effective_project if tier == FREE_TIER_ID else "",
tier_id=tier,
source=source,
)

File diff suppressed because it is too large Load Diff

View File

@ -437,10 +437,6 @@ class ChatCompletionsTransport(ProviderTransport):
extra_body["extra_body"] = openai_compat_extra
elif raw_thinking_config:
extra_body["thinking_config"] = raw_thinking_config
elif provider_name in {"google-gemini-cli", "google-antigravity"}:
thinking_config = _build_gemini_thinking_config(model, reasoning_config)
if thinking_config:
extra_body["thinking_config"] = thinking_config
# Merge any pre-built extra_body additions
additions = params.get("extra_body_additions")

View File

@ -74,7 +74,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [
priority: 4
},
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
name: 'DeepSeek',

View File

@ -132,9 +132,9 @@ describe('settings helpers', () => {
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
// HERMES_QWEN_ shares the HERMES_ stem with other integrations.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
expect(providerGroup('GEMINI_API_KEY')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {

View File

@ -150,7 +150,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/exit', '/footer', '/gateway', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],

2
cli.py
View File

@ -7837,8 +7837,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_model_switch(cmd_original)
elif canonical == "codex-runtime":
self._handle_codex_runtime(cmd_original)
elif canonical == "gquota":
self._handle_gquota_command(cmd_original)
elif canonical == "personality":
# Use original case (handler lowercases the personality name itself)

View File

@ -138,13 +138,6 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = {
"spotify": "Spotify",
}
# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)
DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google"
GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry
# Google Antigravity OAuth (Antigravity Code Assist backend)
DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL = "antigravity-pa://google"
# LM Studio's default no-auth mode still requires *some* non-empty bearer for
# the API-key code paths (auxiliary_client, runtime resolver) to treat the
# provider as configured. This sentinel is sent only to LM Studio, never to
@ -209,18 +202,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
auth_type="oauth_external",
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_CLOUDCODE_BASE_URL,
),
"google-antigravity": ProviderConfig(
id="google-antigravity",
name="Google Antigravity (OAuth)",
auth_type="oauth_external",
inference_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
),
"lmstudio": ProviderConfig(
id="lmstudio",
name="LM Studio",
@ -1538,8 +1519,7 @@ def resolve_provider(
"github-models": "copilot", "github-model": "copilot",
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
"opencode": "opencode-zen", "zen": "opencode-zen",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
"google-antigravity": "google-antigravity", "google-antigravity-oauth": "google-antigravity", "antigravity": "google-antigravity", "antigravity-oauth": "google-antigravity", "antigravity-cli": "google-antigravity", "agy": "google-antigravity", "agy-cli": "google-antigravity",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
@ -2165,163 +2145,6 @@ def get_qwen_auth_status() -> Dict[str, Any]:
# =============================================================================
# Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist.
#
# Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth).
# The `base_url` here is the marker "cloudcode-pa://google" that run_agent.py
# uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK.
# Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*.
# =============================================================================
def _mark_google_gemini_cli_active(creds: Dict[str, Any]) -> None:
"""Set active_provider to google-gemini-cli in auth.json.
The actual OAuth tokens live in the Google credential file managed by
agent.google_oauth. This function only writes a minimal provider-state
entry (email for display) and sets active_provider so that
get_active_provider() and _model_section_has_credentials() detect the
provider for the setup wizard and status commands.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
state: Dict[str, Any] = {}
if creds.get("email"):
state["email"] = str(creds["email"])
_save_provider_state(auth_store, "google-gemini-cli", state)
_save_auth_store(auth_store)
def resolve_gemini_oauth_runtime_credentials(
*,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""Resolve runtime OAuth creds for google-gemini-cli."""
try:
from agent.google_oauth import (
GoogleOAuthError,
_credentials_path,
get_valid_access_token,
load_credentials,
)
except ImportError as exc:
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 = DEFAULT_GEMINI_CLOUDCODE_BASE_URL
return {
"provider": "google-gemini-cli",
"base_url": base_url,
"api_key": access_token,
"source": "google-oauth",
"expires_at_ms": (creds.expires_ms if creds else None),
"auth_file": str(_credentials_path()),
"email": (creds.email if creds else "") or "",
"project_id": (creds.project_id 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": creds.expires_ms,
"email": creds.email,
"project_id": creds.project_id,
}
def resolve_antigravity_oauth_runtime_credentials(
*,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""Resolve runtime OAuth creds for google-antigravity."""
try:
from agent.antigravity_oauth import (
AntigravityOAuthError,
_credentials_path,
get_valid_access_token,
load_credentials,
)
except ImportError as exc:
raise AuthError(
f"agent.antigravity_oauth is not importable: {exc}",
provider="google-antigravity",
code="antigravity_oauth_module_missing",
) from exc
try:
access_token = get_valid_access_token(force_refresh=force_refresh)
except AntigravityOAuthError as exc:
raise AuthError(
str(exc),
provider="google-antigravity",
code=exc.code,
) from exc
creds = load_credentials()
return {
"provider": "google-antigravity",
"base_url": DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
"api_key": access_token,
"source": "antigravity-oauth",
"expires_at_ms": (creds.expires_ms if creds else None),
"auth_file": str(_credentials_path()),
"email": (creds.email if creds else "") or "",
"project_id": (creds.project_id if creds else "") or "",
}
def get_antigravity_oauth_auth_status() -> Dict[str, Any]:
"""Return a status dict for `hermes auth list` / `hermes status`."""
try:
from agent.antigravity_oauth import _credentials_path, load_credentials
except ImportError:
return {"logged_in": False, "error": "agent.antigravity_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": "antigravity-oauth",
"api_key": creds.access_token,
"expires_at_ms": creds.expires_ms,
"email": creds.email,
"project_id": creds.project_id,
}
# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
# =============================================================================
@ -6265,10 +6088,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_xai_oauth_auth_status()
if target == "qwen-oauth":
return get_qwen_auth_status()
if target == "google-gemini-cli":
return get_gemini_oauth_auth_status()
if target == "google-antigravity":
return get_antigravity_oauth_auth_status()
if target == "minimax-oauth":
return get_minimax_oauth_auth_status()
if target == "copilot-acp":

View File

@ -34,7 +34,7 @@ from hermes_cli.secret_prompt import masked_secret_prompt
# Providers that support OAuth login in addition to API keys.
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "google-antigravity", "minimax-oauth"}
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"}
def _get_custom_provider_names() -> list:
@ -314,7 +314,7 @@ def auth_add_command(args) -> None:
_oauth_default_label(provider, len(pool.entries()) + 1),
)
# Add a distinct, self-contained pool entry per account (matching the
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
# xai-oauth / qwen-oauth patterns) instead of
# routing through the singleton ``_save_codex_tokens`` save path.
# The singleton round-trip collapsed every added account into the
# latest login: a second ``hermes auth add openai-codex`` overwrote
@ -364,49 +364,6 @@ def auth_add_command(args) -> None:
print(f'Saved {provider} OAuth credentials: "{shown_label}"')
return
if provider == "google-gemini-cli":
from agent.google_oauth import run_gemini_oauth_login_pure
creds = run_gemini_oauth_login_pure()
auth_mod._mark_google_gemini_cli_active(creds)
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 == "google-antigravity":
from agent.antigravity_oauth import run_antigravity_oauth_login_pure
creds = run_antigravity_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}:antigravity_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":
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
auth_mod._mark_qwen_oauth_active(creds)

View File

@ -947,52 +947,6 @@ class CLICommandsMixin:
_cprint(f" Original session: {parent_session_id}")
_cprint(f" Branch session: {new_session_id}")
def _handle_gquota_command(self, cmd_original: str) -> None:
"""Show Google Gemini Code Assist quota usage for the current OAuth account."""
try:
from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials
from agent.google_code_assist import retrieve_user_quota, CodeAssistError
except ImportError as exc:
self._console_print(f" [red]Gemini modules unavailable: {exc}[/]")
return
try:
access_token = get_valid_access_token()
except GoogleOAuthError as exc:
self._console_print(f" [yellow]{exc}[/]")
self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
return
creds = load_credentials()
project_id = (creds.project_id if creds else "") or ""
try:
buckets = retrieve_user_quota(access_token, project_id=project_id)
except CodeAssistError as exc:
self._console_print(f" [red]Quota lookup failed:[/] {exc}")
return
if not buckets:
self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
return
# Sort for stable display, group by model
buckets.sort(key=lambda b: (b.model_id, b.token_type))
self._console_print()
self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
self._console_print()
for b in buckets:
pct = max(0.0, min(1.0, b.remaining_fraction))
width = 20
filled = int(round(pct * width))
bar = "" * filled + "" * (width - filled)
pct_str = f"{int(pct * 100):3d}%"
header = b.model_id
if b.token_type:
header += f" [{b.token_type}]"
self._console_print(f" {header:40s} {bar} {pct_str}")
self._console_print()
def _handle_personality_command(self, cmd: str):
"""Handle the /personality command to set predefined personalities."""
from cli import save_config_value

View File

@ -128,8 +128,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
cli_only=True),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"),

View File

@ -169,8 +169,8 @@ _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# the dashboard. ``config.yaml`` is the supported surface for these.
#
# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate
# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,
# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The
# integration credentials follow that prefix (HERMES_LANGFUSE_PUBLIC_KEY,
# HERMES_SPOTIFY_CLIENT_ID, ...). The
# denylist is name-by-name on purpose so the gate stays narrow and
# doesn't accidentally break provider setup wizards.
#
@ -3082,62 +3082,6 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"HERMES_GEMINI_CLIENT_ID": {
"description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)",
"prompt": "Google OAuth client ID (optional — leave empty to use the public default)",
"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)",
"prompt": "Google OAuth client secret (optional)",
"url": "https://console.cloud.google.com/apis/credentials",
"password": True,
"category": "provider",
"advanced": True,
},
"HERMES_GEMINI_PROJECT_ID": {
"description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)",
"prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"HERMES_ANTIGRAVITY_CLIENT_ID": {
"description": "Google OAuth client ID for google-antigravity (optional; discovered from agy when omitted)",
"prompt": "Antigravity OAuth client ID (optional — leave empty to discover from agy)",
"url": "https://console.cloud.google.com/apis/credentials",
"password": False,
"category": "provider",
"advanced": True,
},
"HERMES_ANTIGRAVITY_CLIENT_SECRET": {
"description": "Google OAuth client secret for google-antigravity (optional)",
"prompt": "Antigravity OAuth client secret (optional)",
"url": "https://console.cloud.google.com/apis/credentials",
"password": True,
"category": "provider",
"advanced": True,
},
"HERMES_ANTIGRAVITY_CLI_PATH": {
"description": "Path to agy/Antigravity CLI for OAuth client credential discovery",
"prompt": "Antigravity CLI path (leave empty to search PATH/default locations)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"HERMES_ANTIGRAVITY_PROJECT_ID": {
"description": "GCP project ID for Antigravity OAuth (auto-discovered when omitted)",
"prompt": "GCP project ID for Antigravity OAuth (leave empty to auto-discover)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"OPENCODE_ZEN_API_KEY": {
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
"prompt": "OpenCode Zen API key",

View File

@ -158,12 +158,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
that direct-key problem into the final blocking summary.
"""
normalized = (provider_label or "").strip().lower()
if normalized in {"google / gemini", "gemini"}:
try:
from hermes_cli.auth import get_gemini_oauth_auth_status
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
if normalized == "minimax":
try:
from hermes_cli.auth import get_minimax_oauth_auth_status
@ -1077,7 +1071,6 @@ def run_doctor(args):
from hermes_cli.auth import (
get_nous_auth_status,
get_codex_auth_status,
get_gemini_oauth_auth_status,
get_minimax_oauth_auth_status,
)
@ -1105,20 +1098,6 @@ def run_doctor(args):
"from an existing Codex CLI login)"
)
gemini_status = get_gemini_oauth_auth_status()
if gemini_status.get("logged_in"):
email = gemini_status.get("email") or ""
project = gemini_status.get("project_id") or ""
pieces = []
if email:
pieces.append(email)
if project:
pieces.append(f"project={project}")
suffix = f" ({', '.join(pieces)})" if pieces else ""
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
else:
check_warn("Google Gemini OAuth", "(not logged in)")
minimax_status = get_minimax_oauth_auth_status()
if minimax_status.get("logged_in"):
region = minimax_status.get("region", "global")

View File

@ -602,8 +602,6 @@ from hermes_cli.model_setup_flows import (
_model_flow_xai_oauth,
_model_flow_qwen_oauth,
_model_flow_minimax_oauth,
_model_flow_google_gemini_cli,
_model_flow_google_antigravity,
_model_flow_custom,
_model_flow_azure_foundry,
_model_flow_named_custom,
@ -3073,10 +3071,6 @@ def select_provider_and_model(args=None):
_model_flow_qwen_oauth(config, current_model)
elif selected_provider == "minimax-oauth":
_model_flow_minimax_oauth(config, current_model, args=args)
elif selected_provider == "google-gemini-cli":
_model_flow_google_gemini_cli(config, current_model)
elif selected_provider == "google-antigravity":
_model_flow_google_antigravity(config, current_model)
elif selected_provider == "copilot-acp":
_model_flow_copilot_acp(config, current_model)
elif selected_provider == "copilot":
@ -11254,7 +11248,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", "google-gemini-cli", "google-antigravity", "xai", "bedrock", "azure-foundry",
"anthropic", "gemini", "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",

View File

@ -633,142 +633,6 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
_update_config_for_provider("minimax-oauth", creds["base_url"])
print(f"\u2713 Using MiniMax model: {selected}")
def _model_flow_google_gemini_cli(_config, current_model=""):
"""Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.
Flow:
1. Show upfront warning about Google's ToS stance (per opencode-gemini-auth).
2. If creds missing, run PKCE browser OAuth via agent.google_oauth.
3. Resolve project context (env -> config -> auto-discover -> free tier).
4. Prompt user to pick a model.
5. Save to ~/.hermes/config.yaml.
"""
from hermes_cli.auth import (
DEFAULT_GEMINI_CLOUDCODE_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
print()
print("⚠ Google considers using the Gemini CLI OAuth client with third-party")
print(" software a policy violation. Some users have reported account")
print(" restrictions. You can use your own API key via 'gemini' provider")
print(" for the lowest-risk experience.")
print()
try:
proceed = input("Continue with OAuth login? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("Cancelled.")
return
if proceed not in {"y", "yes"}:
print("Cancelled.")
return
status = get_gemini_oauth_auth_status()
if not status.get("logged_in"):
try:
from agent.google_oauth import resolve_project_id_from_env, start_oauth_flow
env_project = resolve_project_id_from_env()
start_oauth_flow(force_relogin=True, project_id=env_project)
except Exception as exc:
print(f"OAuth login failed: {exc}")
return
# Verify creds resolve + trigger project discovery
try:
creds = resolve_gemini_oauth_runtime_credentials(force_refresh=False)
project_id = creds.get("project_id", "")
if project_id:
print(f" Using GCP project: {project_id}")
else:
print(
" No GCP project configured — free tier will be auto-provisioned on first request."
)
except Exception as exc:
print(f"Failed to resolve Gemini credentials: {exc}")
return
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-3-flash-preview")
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="google-gemini-cli",
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider(
"google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL
)
print(
f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)"
)
else:
print("No change.")
def _model_flow_google_antigravity(_config, current_model=""):
"""Google Antigravity OAuth via Antigravity Code Assist.
Antigravity is Google's consumer successor to the Gemini CLI. It reuses the
Code Assist backend with a distinct OAuth client + scopes. Leaves the
`google-gemini-cli` provider (Enterprise Code Assist) untouched.
"""
from hermes_cli.auth import (
DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
get_antigravity_oauth_auth_status,
resolve_antigravity_oauth_runtime_credentials,
_prompt_model_selection,
_save_model_choice,
_update_config_for_provider,
)
from hermes_cli.models import provider_model_ids
status = get_antigravity_oauth_auth_status()
if not status.get("logged_in"):
try:
from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow
env_project = resolve_project_id_from_env()
start_oauth_flow(force_relogin=True, project_id=env_project)
except Exception as exc:
print(f"OAuth login failed: {exc}")
return
try:
creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False)
project_id = creds.get("project_id", "")
if project_id:
print(f" Using Antigravity project: {project_id}")
except Exception as exc:
print(f"Failed to resolve Antigravity credentials: {exc}")
return
models = provider_model_ids("google-antigravity")
default = current_model or (models[0] if models else "gemini-3-flash-agent")
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="google-antigravity",
confirm_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
)
if selected:
_save_model_choice(selected)
_update_config_for_provider(
"google-antigravity", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL
)
print(
f"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)"
)
else:
print("No change.")
def _model_flow_custom(config):
"""Custom endpoint: collect URL, API key, and model name.

View File

@ -265,26 +265,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini-3.5-flash",
"gemini-3.1-flash-lite-preview",
],
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
# Code Assist serves two flash slugs with different access gates
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
# that subscription/free-tier OAuth users actually reach, while
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
# aren't stuck with a slug cloudcode-pa 404s for them.
"gemini-3-flash-preview",
"gemini-3.5-flash",
],
"google-antigravity": [
"gemini-3-flash-agent",
"gemini-3.5-flash-low",
"gemini-pro-agent",
"gemini-3.1-pro-low",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
"gpt-oss-120b-medium",
],
"zai": [
"glm-5.2",
"glm-5.1",
@ -1037,8 +1017,6 @@ 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("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)"),
ProviderEntry("google-antigravity", "Google Antigravity (OAuth)", "Google Antigravity via OAuth + Code Assist (Gemini 3.5/3.1, Claude, GPT-OSS where entitled)"),
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)"),
@ -1109,7 +1087,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {
"kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]),
"minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]),
"xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]),
"google": ("Google Gemini", "AI Studio API or OAuth + Code Assist", ["gemini", "google-gemini-cli"]),
"google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]),
"openai": ("OpenAI", "Codex CLI or direct OpenAI API", ["openai-codex", "openai-api"]),
"opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]),
"copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]),
@ -1230,14 +1208,6 @@ _PROVIDER_ALIASES = {
"qwen": "alibaba",
"alibaba-cloud": "alibaba",
"qwen-portal": "qwen-oauth",
"gemini-cli": "google-gemini-cli",
"gemini-oauth": "google-gemini-cli",
"antigravity": "google-antigravity",
"antigravity-oauth": "google-antigravity",
"antigravity-cli": "google-antigravity",
"google-antigravity-oauth": "google-antigravity",
"agy": "google-antigravity",
"agy-cli": "google-antigravity",
"hf": "huggingface",
"hugging-face": "huggingface",
"huggingface-hub": "huggingface",
@ -1805,13 +1775,10 @@ _AGGREGATOR_PROVIDERS = frozenset(
)
# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
# (e.g. google-antigravity serves Claude / Gemini / GPT-OSS where the account
# is entitled). For bare short-alias resolution (`sonnet`, `opus`, ...) these
# must NOT hijack the alias away from the model's native vendor provider
# (`anthropic`, `gemini`, ...). They're tried only as a last resort, after
# every native-vendor catalog. They are NOT aggregators (an explicit switch TO
# them is still valid), so they stay out of _AGGREGATOR_PROVIDERS.
_BORROWED_MODEL_PROVIDERS = frozenset({"google-antigravity"})
# would be listed here (tried only as a last resort for bare short-alias
# resolution, after every native-vendor catalog, so they never hijack an alias
# away from the model's native vendor). None are currently defined.
_BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()
def _resolve_static_model_alias(
@ -1863,9 +1830,9 @@ def _resolve_static_model_alias(
if provider in current_keys and (matched := _match(provider)):
return provider, matched
# Last resort: providers that re-expose other vendors' models (e.g.
# google-antigravity serving Claude). Only reached when no native-vendor
# catalog matched — so `sonnet` resolves to anthropic, not antigravity.
# Last resort: providers that re-expose other vendors' models. Only reached
# when no native-vendor catalog matched — so `sonnet` resolves to anthropic.
# None are currently defined (_BORROWED_MODEL_PROVIDERS is empty).
for provider in _BORROWED_MODEL_PROVIDERS:
if provider in current_keys and (matched := _match(provider)):
return provider, matched
@ -2240,32 +2207,6 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
return merged
def _fetch_antigravity_models(*, force_refresh: bool = False) -> list[str]:
try:
from agent import antigravity_oauth
from agent.antigravity_code_assist import (
fetch_available_models_with_fallbacks,
load_code_assist,
parse_agent_model_ids,
)
from hermes_cli.auth import resolve_antigravity_oauth_runtime_credentials
creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=force_refresh)
access_token = str(creds.get("api_key") or "").strip()
project_id = str(creds.get("project_id") or "").strip()
if not access_token:
return []
if not project_id:
info = load_code_assist(access_token)
project_id = info.project_id
if project_id:
antigravity_oauth.update_project_ids(project_id=project_id, managed_project_id=project_id)
payload = fetch_available_models_with_fallbacks(access_token, project_id=project_id)
return parse_agent_model_ids(payload)
except Exception:
return []
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
"""Return the best known model catalog for a provider.
@ -2296,10 +2237,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return get_codex_model_ids(access_token=access_token)
if normalized == "xai-oauth":
return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", [])))
if normalized == "google-antigravity":
live = _fetch_antigravity_models(force_refresh=force_refresh)
if live:
return live
if normalized in {"copilot", "copilot-acp"}:
try:
live = _fetch_github_models(_resolve_copilot_catalog_api_key())

View File

@ -57,7 +57,7 @@ _ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
class ProviderDescriptor:
"""One provider, as seen by every surface (CLI picker + both GUI tabs)."""
slug: str # canonical id, e.g. "google-gemini-cli"
slug: str # canonical id, e.g. "openai-codex"
label: str # human display name
description: str # one-line description
auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk

View File

@ -76,16 +76,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
base_url_override="https://portal.qwen.ai/v1",
base_url_env_var="HERMES_QWEN_BASE_URL",
),
"google-gemini-cli": HermesOverlay(
transport="openai_chat",
auth_type="oauth_external",
base_url_override="cloudcode-pa://google",
),
"google-antigravity": HermesOverlay(
transport="openai_chat",
auth_type="oauth_external",
base_url_override="antigravity-pa://google",
),
"lmstudio": HermesOverlay(
transport="openai_chat",
auth_type="api_key",
@ -315,18 +305,6 @@ ALIASES: Dict[str, str] = {
"alibaba-coding": "alibaba-coding-plan",
"alibaba_coding_plan": "alibaba-coding-plan",
# google-gemini-cli (OAuth + Code Assist)
"gemini-cli": "google-gemini-cli",
"gemini-oauth": "google-gemini-cli",
# google-antigravity (OAuth + Antigravity Code Assist)
"antigravity": "google-antigravity",
"antigravity-oauth": "google-antigravity",
"antigravity-cli": "google-antigravity",
"google-antigravity-oauth": "google-antigravity",
"agy": "google-antigravity",
"agy-cli": "google-antigravity",
# huggingface
"hf": "huggingface",
"hugging-face": "huggingface",

View File

@ -26,8 +26,6 @@ from hermes_cli.auth import (
resolve_codex_runtime_credentials,
resolve_xai_oauth_runtime_credentials,
resolve_qwen_runtime_credentials,
resolve_gemini_oauth_runtime_credentials,
resolve_antigravity_oauth_runtime_credentials,
resolve_api_key_provider_credentials,
resolve_external_process_provider_credentials,
has_usable_secret,
@ -332,12 +330,6 @@ def _resolve_runtime_from_pool_entry(
elif provider == "qwen-oauth":
api_mode = "chat_completions"
base_url = base_url or DEFAULT_QWEN_BASE_URL
elif provider == "google-gemini-cli":
api_mode = "chat_completions"
base_url = base_url or "cloudcode-pa://google"
elif provider == "google-antigravity":
api_mode = "chat_completions"
base_url = base_url or "antigravity-pa://google"
elif provider == "minimax-oauth":
# MiniMax OAuth tokens are valid only against the Anthropic Messages
# compatible endpoint. Do not honor stale model.api_mode values from a
@ -1618,46 +1610,6 @@ def resolve_runtime_provider(
"requested_provider": requested_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", ""),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "google-oauth"),
"expires_at_ms": creds.get("expires_at_ms"),
"email": creds.get("email", ""),
"project_id": creds.get("project_id", ""),
"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 == "google-antigravity":
try:
creds = resolve_antigravity_oauth_runtime_credentials()
return {
"provider": "google-antigravity",
"api_mode": "chat_completions",
"base_url": creds.get("base_url", ""),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "antigravity-oauth"),
"expires_at_ms": creds.get("expires_at_ms"),
"email": creds.get("email", ""),
"project_id": creds.get("project_id", ""),
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
raise
logger.info("Google Antigravity OAuth credentials failed; "
"falling through to next provider.")
if provider == "copilot-acp":
creds = resolve_external_process_provider_credentials(provider)
return {

View File

@ -420,7 +420,6 @@ TIPS = [
'/platforms shows gateway and messaging-platform connection status right from inside chat.',
'/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',
'/toolsets lists every available toolset so you know what -t/--toolsets accepts.',
'/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',
'/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',
'/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',
'/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',

View File

@ -5640,23 +5640,6 @@ def _claude_code_only_status() -> Dict[str, Any]:
return {"logged_in": False, "source": None}
def _gemini_cli_status() -> Dict[str, Any]:
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
try:
from hermes_cli import auth as hauth
raw = hauth.get_gemini_oauth_auth_status()
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "google_oauth",
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
}
def _copilot_acp_status() -> Dict[str, Any]:
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
@ -5736,14 +5719,6 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
{
"id": "google-gemini-cli",
"name": "Google Gemini (OAuth + Code Assist)",
"flow": "external",
"cli_command": "hermes auth add google-gemini-cli",
"docs_url": "https://ai.google.dev/gemini-api/docs",
"status_fn": _gemini_cli_status,
},
{
"id": "copilot-acp",
"name": "GitHub Copilot (ACP)",

View File

@ -1,80 +0,0 @@
# Gemini OAuth Provider — Implementation Plan
## Goal
Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys.
## Architecture Decision
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta`
- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk
- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed
- Our own OAuth credentials — NOT sharing tokens with Gemini CLI
## OAuth Flow
- **Type:** Authorization Code + PKCE (S256) — same pattern as clawdbot/pi-mono
- **Auth URL:** `https://accounts.google.com/o/oauth2/v2/auth`
- **Token URL:** `https://oauth2.googleapis.com/token`
- **Redirect:** `http://localhost:8085/oauth2callback` (localhost callback server)
- **Fallback:** Manual URL paste for remote/WSL/headless environments
- **Scopes:** `https://www.googleapis.com/auth/cloud-platform`, `https://www.googleapis.com/auth/userinfo.email`
- **PKCE:** S256 code challenge, 32-byte random verifier
## Client ID
- Need to register a "Desktop app" OAuth client on a Nous Research GCP project
- Ship client_id + client_secret in code (Google considers installed app secrets non-confidential)
- Alternatively: accept user-provided client_id via env vars as override
## Token Lifecycle
- Store at `~/.hermes/gemini_oauth.json` (NOT sharing with `~/.gemini/oauth_creds.json`)
- Fields: `client_id`, `client_secret`, `refresh_token`, `access_token`, `expires_at`, `email`
- File permissions: 0o600
- Before each API call: check expiry, refresh if within 5 min of expiration
- Refresh: POST to token URL with `grant_type=refresh_token`
- File locking for concurrent access (multiple agent sessions)
## API Integration
- Base URL: `https://generativelanguage.googleapis.com/v1beta`
- Auth: native Gemini API authentication handled by the provider adapter
- api_mode: `chat_completions` (standard facade over native transport)
- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc.
## Files to Create/Modify
### New files
1. `agent/google_oauth.py` — OAuth flow (PKCE, localhost server, token exchange, refresh)
- `start_oauth_flow()` — opens browser, starts callback server
- `exchange_code()` — code → tokens
- `refresh_access_token()` — refresh flow
- `load_credentials()` / `save_credentials()` — file I/O with locking
- `get_valid_access_token()` — check expiry, refresh if needed
- ~200 lines
### Existing files to modify
2. `hermes_cli/auth.py` — Add ProviderConfig for "gemini" with auth_type="oauth_google"
3. `hermes_cli/models.py` — Add Gemini model catalog
4. `hermes_cli/runtime_provider.py` — Add gemini branch (read OAuth token, build OpenAI client)
5. `hermes_cli/main.py` — Add `_model_flow_gemini()`, add to provider choices
6. `hermes_cli/setup.py` — Add gemini auth flow (trigger browser OAuth)
7. `run_agent.py` — Token refresh before API calls (like Copilot pattern)
8. `agent/auxiliary_client.py` — Add gemini to aux resolution chain
9. `agent/model_metadata.py` — Add Gemini model context lengths
### Tests
10. `tests/agent/test_google_oauth.py` — OAuth flow unit tests
11. `tests/test_api_key_providers.py` — Add gemini provider test
### Docs
12. `website/docs/getting-started/quickstart.md` — Add gemini to provider table
13. `website/docs/user-guide/configuration.md` — Gemini setup section
14. `website/docs/reference/environment-variables.md` — New env vars
## Estimated scope
~400 lines new code, ~150 lines modifications, ~100 lines tests, ~50 lines docs = ~700 lines total
## Prerequisites
- Nous Research GCP project with Desktop OAuth client registered
- OR: accept user-provided client_id via HERMES_GEMINI_CLIENT_ID env var
## Reference implementations
- clawdbot: `extensions/google/oauth.flow.ts` (PKCE + localhost server)
- pi-mono: `packages/ai/src/utils/oauth/google-gemini-cli.ts` (same flow)
- hermes-agent Copilot OAuth: `hermes_cli/main.py` `_copilot_device_flow()` (different flow type but same lifecycle pattern)

View File

@ -1,11 +1,9 @@
"""Google Gemini provider profiles.
gemini: Google AI Studio (API key) uses GeminiNativeClient
google-gemini-cli: Google Cloud Code Assist (OAuth) uses GeminiCloudCodeClient
google-antigravity: Google Antigravity Code Assist (OAuth) uses AntigravityCloudCodeClient
Both report api_mode="chat_completions" but use custom native clients
that bypass the standard OpenAI transport. The profile captures auth
Reports api_mode="chat_completions" but uses a custom native client
that bypasses the standard OpenAI transport. The profile captures auth
and endpoint metadata for auth.py / runtime_provider.py migration, and
carries the thinking_config translation hook so the transport's profile
path produces the same extra_body shape the legacy flag path did.
@ -60,31 +58,4 @@ gemini = GeminiProfile(
default_aux_model="gemini-3.5-flash",
)
google_gemini_cli = GeminiProfile(
name="google-gemini-cli",
aliases=("gemini-cli", "gemini-oauth"),
api_mode="chat_completions",
env_vars=(), # OAuth — no API key
base_url="cloudcode-pa://google", # Cloud Code Assist internal scheme
auth_type="oauth_external",
)
google_antigravity = GeminiProfile(
name="google-antigravity",
aliases=(
"antigravity",
"antigravity-oauth",
"antigravity-cli",
"google-antigravity-oauth",
"agy",
"agy-cli",
),
api_mode="chat_completions",
env_vars=(), # OAuth — no API key
base_url="antigravity-pa://google", # Antigravity Code Assist internal scheme
auth_type="oauth_external",
)
register_provider(gemini)
register_provider(google_gemini_cli)
register_provider(google_antigravity)

View File

@ -273,7 +273,7 @@ def _pool_may_recover_from_rate_limit(
return False
# CloudCode / Gemini CLI quotas are account-wide — all pool entries share
# the same throttle window, so rotation can't recover. Prefer fallback.
if provider == "google-gemini-cli" or str(base_url or "").startswith("cloudcode-pa://"):
if str(base_url or "").startswith("cloudcode-pa://"):
return False
return len(pool.entries()) > 1
@ -4093,8 +4093,7 @@ class AIAgent:
if pool is None:
return False
if (
self.provider == "google-gemini-cli"
or str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
):
# CloudCode/Gemini quota windows are usually account-level throttles.
# Prefer the configured fallback immediately instead of waiting out

View File

@ -336,7 +336,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer
/commands [page] Browse all commands (gateway)
/usage Token usage
/insights [days] Usage analytics
/gquota Show Google Gemini Code Assist quota usage (CLI)
/status Session info (gateway)
/profile Active profile info
/debug Upload debug report (system info + logs) and get shareable links

View File

@ -1,405 +0,0 @@
"""Tests for the google-antigravity OAuth + Antigravity Code Assist provider."""
from __future__ import annotations
import json
import os
import stat
import time
import threading
import urllib.parse
from io import BytesIO
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch, tmp_path):
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_ANTIGRAVITY_CLIENT_ID",
"HERMES_ANTIGRAVITY_CLIENT_SECRET",
"HERMES_ANTIGRAVITY_CLI_PATH",
"HERMES_ANTIGRAVITY_PROJECT_ID",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_PROJECT_ID",
"LOCALAPPDATA",
"APPDATA",
"ProgramFiles",
"ProgramFiles(x86)",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr("shutil.which", lambda _: None)
try:
from agent import antigravity_oauth
antigravity_oauth._discovered_creds_cache.clear()
except Exception:
pass
return home
class TestAntigravityCredentials:
def test_save_load_uses_separate_file_and_0600_permissions(self):
from agent.antigravity_oauth import (
AntigravityCredentials,
_credentials_path,
load_credentials,
save_credentials,
)
save_credentials(AntigravityCredentials(
access_token="at",
refresh_token="rt",
expires_ms=int((time.time() + 3600) * 1000),
email="user@example.com",
project_id="proj-123",
))
assert _credentials_path().name == "antigravity_oauth.json"
loaded = load_credentials()
assert loaded is not None
assert loaded.refresh_token == "rt"
assert loaded.project_id == "proj-123"
if os.name != "nt":
assert stat.S_IMODE(_credentials_path().stat().st_mode) == 0o600
def test_env_override_client_id(self, monkeypatch):
from agent.antigravity_oauth import _get_client_id
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "custom.apps.googleusercontent.com")
assert _get_client_id() == "custom.apps.googleusercontent.com"
def test_env_override_client_secret(self, monkeypatch):
from agent.antigravity_oauth import _get_client_secret
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "custom-secret")
assert _get_client_secret() == "custom-secret"
def test_discovers_client_credentials_from_configured_agy_path(self, tmp_path, monkeypatch):
from agent import antigravity_oauth
fake_client_id = (
"1071006060591-"
+ "fakefakefakefakefakefakefake"
+ ".apps.google"
+ "usercontent.com"
)
fake_client_secret = "GOC" + "SPX-" + "fake-secret-value-placeholde"
fake_agy = tmp_path / "agy.exe"
fake_agy.write_text(
f'oauthClientId="{fake_client_id}";\n'
f'oauthClientSecret="{fake_client_secret}";\n',
encoding="utf-8",
)
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLI_PATH", str(fake_agy))
antigravity_oauth._discovered_creds_cache.clear()
assert antigravity_oauth._get_client_id().startswith("1071006060591-")
assert antigravity_oauth._get_client_secret() == fake_client_secret
def test_missing_discovery_falls_back_to_public_default(self, monkeypatch):
# With no env override and no discoverable agy install, the public
# baked-in Antigravity desktop OAuth client is used as the floor so
# users without `agy` installed can still authenticate (PKCE makes the
# installed-app "secret" non-confidential, same as gemini-cli).
from agent import antigravity_oauth
from agent.antigravity_oauth import (
_DEFAULT_CLIENT_ID,
_DEFAULT_CLIENT_SECRET,
_require_client_id,
)
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", raising=False)
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLI_PATH", raising=False)
antigravity_oauth._discovered_creds_cache.clear()
assert _require_client_id() == _DEFAULT_CLIENT_ID
assert antigravity_oauth._get_client_secret() == _DEFAULT_CLIENT_SECRET
assert _DEFAULT_CLIENT_ID.startswith("1071006060591-")
def test_pkce_challenge_is_s256(self):
import base64
import hashlib
from agent.antigravity_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
assert 43 <= len(verifier) <= 128
def test_exchange_code_posts_pkce_payload(self, monkeypatch):
from agent import antigravity_oauth
captured = {}
def fake_post(url, data, timeout):
captured.update({"url": url, "data": data, "timeout": timeout})
return {"access_token": "at"}
monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "client.apps.googleusercontent.com")
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "secret")
assert antigravity_oauth.exchange_code("code", "verifier", "http://localhost/cb") == {
"access_token": "at"
}
assert captured["url"] == antigravity_oauth.TOKEN_ENDPOINT
assert captured["data"]["grant_type"] == "authorization_code"
assert captured["data"]["code_verifier"] == "verifier"
assert captured["data"]["redirect_uri"] == "http://localhost/cb"
assert captured["data"]["client_id"] == "client.apps.googleusercontent.com"
assert captured["data"]["client_secret"] == "secret"
def test_refresh_tries_discovered_client_secret_candidates(self, monkeypatch):
from agent import antigravity_oauth
from agent.antigravity_oauth import AntigravityOAuthError
calls = []
monkeypatch.setattr(
antigravity_oauth,
"_iter_client_credential_candidates",
lambda: [
("client.apps.googleusercontent.com", "wrong-secret"),
("client.apps.googleusercontent.com", "right-secret"),
],
)
def fake_post(url, data, timeout):
calls.append(data["client_secret"])
if data["client_secret"] == "wrong-secret":
raise AntigravityOAuthError(
"invalid client",
code="antigravity_oauth_invalid_client",
)
return {"access_token": "new-token", "expires_in": 3600}
monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
assert antigravity_oauth.refresh_access_token("refresh-token")["access_token"] == "new-token"
assert calls == ["wrong-secret", "right-secret"]
def test_invalid_grant_refresh_clears_credentials(self, monkeypatch):
from agent import antigravity_oauth
from agent.antigravity_oauth import (
AntigravityCredentials,
AntigravityOAuthError,
load_credentials,
save_credentials,
)
save_credentials(AntigravityCredentials(
access_token="expired",
refresh_token="rt",
expires_ms=int((time.time() - 3600) * 1000),
))
def invalid_grant(_refresh_token):
raise AntigravityOAuthError("revoked", code="antigravity_oauth_invalid_grant")
monkeypatch.setattr(antigravity_oauth, "refresh_access_token", invalid_grant)
with pytest.raises(AntigravityOAuthError, match="revoked"):
antigravity_oauth.get_valid_access_token()
assert load_credentials() is None
def test_callback_handler_captures_code_on_handler_class(self):
from agent.antigravity_oauth import CALLBACK_PATH, _OAuthCallbackHandler
handler_cls = type("TestAntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {})
handler_cls.expected_state = "state-123"
handler_cls.captured_code = None
handler_cls.captured_error = None
handler_cls.ready = threading.Event()
handler = handler_cls.__new__(handler_cls)
handler.path = CALLBACK_PATH + "?" + urllib.parse.urlencode({
"state": "state-123",
"code": "auth-code",
})
handler.wfile = BytesIO()
responses = []
headers = []
handler.send_response = lambda code: responses.append(code)
handler.send_header = lambda key, value: headers.append((key, value))
handler.end_headers = lambda: None
handler.do_GET()
assert responses == [200]
assert handler_cls.captured_code == "auth-code"
assert handler_cls.captured_error is None
assert handler_cls.ready.is_set()
assert "captured_code" not in handler.__dict__
class TestAntigravityModelCatalog:
def test_parse_agent_model_ids_prefers_recommended_group(self):
from agent.antigravity_code_assist import parse_agent_model_ids
payload = {
"defaultAgentModelId": "gemini-3-flash-agent",
"agentModelSorts": [
{
"displayName": "Experimental",
"modelIds": ["tab_flash_lite_preview", "chat_23310"],
},
{
"displayName": "Recommended",
"modelIds": [
"gemini-3-flash-agent",
"gemini-3.5-flash-low",
"gemini-3.1-pro-high",
"gemini-pro-agent",
"claude-sonnet-4-6",
],
},
],
"models": [{"id": "gpt-oss-120b-medium"}],
}
assert parse_agent_model_ids(payload) == [
"gemini-3-flash-agent",
"gemini-3.5-flash-low",
"gemini-pro-agent",
"claude-sonnet-4-6",
]
def test_headers_include_antigravity_metadata(self):
from agent.antigravity_code_assist import build_headers
headers = build_headers("tok")
assert headers["Authorization"] == "Bearer tok"
assert headers["User-Agent"].startswith("antigravity/")
assert headers["X-Goog-Api-Client"] == "google-cloud-sdk vscode_cloudshelleditor/0.1"
metadata = json.loads(headers["Client-Metadata"])
assert metadata["ideType"] == "ANTIGRAVITY"
assert metadata["platform"] == "PLATFORM_UNSPECIFIED"
class TestAntigravityClient:
def test_client_exposes_openai_interface(self):
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
client = AntigravityCloudCodeClient(api_key="dummy")
try:
assert hasattr(client, "chat")
assert hasattr(client.chat, "completions")
assert callable(client.chat.completions.create)
finally:
client.close()
def test_create_uses_antigravity_endpoint_and_headers(self, monkeypatch):
from agent import antigravity_oauth
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
from agent.antigravity_code_assist import ANTIGRAVITY_CODE_ASSIST_ENDPOINT
monkeypatch.setattr(antigravity_oauth, "get_valid_access_token", lambda: "live-token")
class _Response:
status_code = 200
def json(self):
return {
"response": {
"candidates": [{
"content": {"parts": [{"text": "ok"}]},
"finishReason": "STOP",
}]
}
}
class _Http:
def __init__(self):
self.calls = []
def post(self, url, json=None, headers=None):
self.calls.append((url, json, headers))
return _Response()
def close(self):
pass
client = AntigravityCloudCodeClient(project_id="proj-123")
client._http = _Http()
try:
result = client.chat.completions.create(
model="gemini-3-flash-agent",
messages=[{"role": "user", "content": "hi"}],
)
finally:
client.close()
assert result.choices[0].message.content == "ok"
url, body, headers = client._http.calls[0]
assert url == f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
assert body["project"] == "proj-123"
assert body["model"] == "gemini-3-flash-agent"
assert headers["Authorization"] == "Bearer live-token"
assert json.loads(headers["Client-Metadata"])["ideType"] == "ANTIGRAVITY"
class TestAntigravityRegistration:
def test_registry_entry_and_aliases(self):
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider
assert "google-antigravity" in PROVIDER_REGISTRY
assert PROVIDER_REGISTRY["google-antigravity"].auth_type == "oauth_external"
assert resolve_provider("antigravity") == "google-antigravity"
assert resolve_provider("antigravity-oauth") == "google-antigravity"
assert resolve_provider("google-antigravity-oauth") == "google-antigravity"
assert resolve_provider("agy") == "google-antigravity"
def test_runtime_provider_raises_when_not_logged_in(self):
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import resolve_runtime_provider
with pytest.raises(AuthError) as exc_info:
resolve_runtime_provider(requested="google-antigravity")
assert exc_info.value.code == "antigravity_oauth_not_logged_in"
def test_runtime_provider_returns_correct_shape_when_logged_in(self):
from agent.antigravity_oauth import AntigravityCredentials, save_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
save_credentials(AntigravityCredentials(
access_token="live-tok",
refresh_token="rt",
expires_ms=int((time.time() + 3600) * 1000),
project_id="my-proj",
email="t@e.com",
))
result = resolve_runtime_provider(requested="google-antigravity")
assert result["provider"] == "google-antigravity"
assert result["api_mode"] == "chat_completions"
assert result["api_key"] == "live-tok"
assert result["base_url"] == "antigravity-pa://google"
assert result["project_id"] == "my-proj"
assert result["email"] == "t@e.com"
def test_provider_model_ids_uses_live_antigravity_catalog(self, monkeypatch):
from hermes_cli import models
monkeypatch.setattr(
models,
"_fetch_antigravity_models",
lambda force_refresh=False: ["gemini-3-flash-agent", "claude-sonnet-4-6"],
)
assert models.provider_model_ids("agy") == [
"gemini-3-flash-agent",
"claude-sonnet-4-6",
]
def test_oauth_capable_set_includes_antigravity(self):
from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS
assert "google-antigravity" in _OAUTH_CAPABLE_PROVIDERS

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ def _pool(entries: int = 2):
def test_cloudcode_provider_skips_pool_rotation():
assert _pool_may_recover_from_rate_limit(
_pool(entries=3),
provider="google-gemini-cli",
provider="auto",
base_url="cloudcode-pa://google",
) is False

View File

@ -404,34 +404,6 @@ class TestChatCompletionsBuildKwargs:
)
assert kw["extra_body"]["extra_body"]["google"]["thinking_config"]["thinking_level"] == "high"
def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport):
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gemini-3-flash-preview",
messages=msgs,
provider_name="google-gemini-cli",
reasoning_config={"enabled": True, "effort": "high"},
)
assert kw["extra_body"]["thinking_config"] == {
"includeThoughts": True,
"thinkingLevel": "high",
}
assert "google" not in kw["extra_body"]
def test_google_antigravity_keeps_top_level_thinking_config(self, transport):
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gemini-3-flash-agent",
messages=msgs,
provider_name="google-antigravity",
reasoning_config={"enabled": True, "effort": "high"},
)
assert kw["extra_body"]["thinking_config"] == {
"includeThoughts": True,
"thinkingLevel": "high",
}
assert "google" not in kw["extra_body"]
def test_gemini_flash_minimal_clamps_to_low(self, transport):
# Gemini 3 Flash documents low/medium/high; "minimal" isn't accepted,
# so clamp it down to "low" rather than forwarding it verbatim.

View File

@ -85,7 +85,6 @@ class TestMaybeApplyCodexAppServerRuntime:
"openrouter",
"xai",
"qwen-oauth",
"google-gemini-cli",
"opencode-zen",
"bedrock",
"",

View File

@ -1,21 +0,0 @@
from unittest.mock import MagicMock, patch
def test_gquota_uses_chat_console_when_tui_is_live():
from agent.google_oauth import GoogleOAuthError
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.console = MagicMock()
cli._app = object()
live_console = MagicMock()
with patch("cli.ChatConsole", return_value=live_console), \
patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \
patch("agent.google_oauth.load_credentials", return_value=None), \
patch("agent.google_code_assist.retrieve_user_quota"):
cli._handle_gquota_command("/gquota")
assert live_console.print.call_count == 2
cli.console.print.assert_not_called()

View File

@ -129,51 +129,6 @@ def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
assert entry["expires_at_ms"] == 1711234567000
def test_auth_add_google_gemini_cli_sets_active_provider(tmp_path, monkeypatch):
"""hermes auth add google-gemini-cli must set active_provider in auth.json.
Tokens are managed by agent.google_oauth (written to the Google credential
file by start_oauth_flow). The auth.json entry must record active_provider
so get_active_provider() and _model_section_has_credentials() detect the
provider without storing tokens that would become stale.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
monkeypatch.setattr(
"agent.google_oauth.run_gemini_oauth_login_pure",
lambda: {
"access_token": "ya29.test-token",
"refresh_token": "google-refresh",
"email": "user@example.com",
"expires_at_ms": 9999999999000,
"project_id": "my-project",
},
)
from hermes_cli.auth_commands import auth_add_command
class _Args:
provider = "google-gemini-cli"
auth_type = "oauth"
api_key = None
label = None
auth_add_command(_Args())
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
assert payload["active_provider"] == "google-gemini-cli"
state = payload["providers"]["google-gemini-cli"]
# Only email stored — no access_token/refresh_token (those live in
# the Google OAuth credential file managed by agent.google_oauth).
assert state.get("email") == "user@example.com"
assert "access_token" not in state
assert "refresh_token" not in state
# pool entry from pool.add_entry() still present for hermes auth list
entries = payload["credential_pool"]["google-gemini-cli"]
entry = next(item for item in entries if item["source"] == "manual:google_pkce")
assert entry["access_token"] == "ya29.test-token"
def test_auth_add_qwen_oauth_sets_active_provider(tmp_path, monkeypatch):
"""hermes auth add qwen-oauth must set active_provider in auth.json.

View File

@ -1056,7 +1056,6 @@ class TestEnvWriteDenylist:
@pytest.mark.parametrize(
"allowed_key",
[
"HERMES_GEMINI_CLIENT_ID",
"HERMES_LANGFUSE_PUBLIC_KEY",
"HERMES_SPOTIFY_CLIENT_ID",
"HERMES_QWEN_BASE_URL",

View File

@ -473,7 +473,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
except Exception:
pass
@ -915,7 +914,6 @@ def _run_doctor_with_healthy_oauth_fallback(
env_key: str,
bad_key: str,
failing_host: str,
gemini_oauth_status: dict,
minimax_oauth_status: dict,
xai_oauth_status: dict | None = None,
) -> str:
@ -952,7 +950,6 @@ def _run_doctor_with_healthy_oauth_fallback(
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)
@ -972,22 +969,12 @@ def _run_doctor_with_healthy_oauth_fallback(
@pytest.mark.parametrize(
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
("env_key", "bad_key", "failing_host", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
[
(
"GOOGLE_API_KEY",
"bad-gemini-key",
"googleapis.com",
{"logged_in": True, "email": "user@example.com"},
{},
None,
"Check GOOGLE_API_KEY in .env",
),
(
"MINIMAX_API_KEY",
"bad-minimax-key",
"minimax.io",
{},
{"logged_in": True, "region": "global"},
None,
"Check MINIMAX_API_KEY in .env",
@ -997,7 +984,6 @@ def _run_doctor_with_healthy_oauth_fallback(
"bad-xai-key",
"api.x.ai",
{},
{},
{"logged_in": True, "auth_mode": "oauth_pkce"},
"Check XAI_API_KEY in .env",
),
@ -1009,7 +995,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
env_key,
bad_key,
failing_host,
gemini_oauth_status,
minimax_oauth_status,
xai_oauth_status,
unexpected_issue,
@ -1020,7 +1005,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
env_key=env_key,
bad_key=bad_key,
failing_host=failing_host,
gemini_oauth_status=gemini_oauth_status,
minimax_oauth_status=minimax_oauth_status,
xai_oauth_status=xai_oauth_status,
)
@ -1062,16 +1046,6 @@ class TestHasHealthyOauthFallbackForXai:
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch):
import sys
from hermes_cli import auth as _auth_mod
# xAI function missing, but Gemini is healthy
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True})
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True
# ---------------------------------------------------------------------------
# ◆ Auth Providers — xAI OAuth display in run_doctor()
@ -1107,7 +1081,6 @@ class TestDoctorXaiOAuthStatus:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
@ -1182,7 +1155,6 @@ class TestDoctorXaiOAuthStatus:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
@ -1214,7 +1186,6 @@ class TestDoctorXaiOAuthStatus:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
@ -1275,7 +1246,6 @@ class TestDoctorCodexCliHintPlacement:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": codex_logged_in})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False})
@ -1317,12 +1287,16 @@ class TestDoctorCodexCliHintPlacement:
def test_hint_never_attaches_to_minimax_row(self, monkeypatch, tmp_path):
out = self._run(monkeypatch, tmp_path, codex_logged_in=False, codex_cli_present=False)
# The MiniMax OAuth row and the hint must not be adjacent — the hint
# belongs to the Codex auth row directly above it.
# The hint belongs to the Codex auth row that precedes it, never to the
# MiniMax row that follows (#27975). The MiniMax row itself must not be
# the hint line, and the hint must sit strictly above MiniMax.
lines = [l for l in out.splitlines() if l.strip()]
codex_idx = next(i for i, l in enumerate(lines) if "OpenAI Codex auth" in l)
hint_idx = next(i for i, l in enumerate(lines) if self._hint_line() in l)
minimax_idx = next(i for i, l in enumerate(lines) if "MiniMax OAuth" in l)
assert self._hint_line() not in lines[minimax_idx - 1]
assert minimax_idx + 1 >= len(lines) or self._hint_line() not in lines[minimax_idx + 1]
# Hint sits under Codex and above MiniMax; the MiniMax row is not the hint.
assert codex_idx < hint_idx < minimax_idx
assert self._hint_line() not in lines[minimax_idx]
class TestDoctorStaleMaxIterationsDrift:

View File

@ -316,41 +316,6 @@ class TestProviderPersistsAfterModelSave:
assert model.get("default") == "minimax-m2.5"
assert model.get("api_mode") == "anthropic_messages"
def test_antigravity_oauth_provider_saved_when_selected(self, config_home):
"""_model_flow_google_antigravity should persist provider/base_url/model together."""
from hermes_cli.main import _model_flow_google_antigravity
from hermes_cli.config import load_config
with patch(
"hermes_cli.auth.get_antigravity_oauth_auth_status",
return_value={"logged_in": True, "email": "user@example.com"},
), patch(
"hermes_cli.auth.resolve_antigravity_oauth_runtime_credentials",
return_value={
"provider": "google-antigravity",
"api_key": "tok",
"base_url": "antigravity-pa://google",
"project_id": "proj-123",
},
), patch(
"hermes_cli.models.provider_model_ids",
return_value=["gemini-3-flash-agent", "claude-sonnet-4-6"],
), patch(
"hermes_cli.auth._prompt_model_selection",
return_value="claude-sonnet-4-6",
):
_model_flow_google_antigravity(load_config(), "old-model")
import yaml
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
assert model.get("provider") == "google-antigravity"
assert model.get("base_url") == "antigravity-pa://google"
assert model.get("default") == "claude-sonnet-4-6"
assert "api_mode" not in model
class TestBaseUrlValidation:

View File

@ -62,8 +62,6 @@ def test_api_key_providers_route_to_keys_oauth_to_accounts():
# api_key → keys
assert by["kilocode"].tab == "keys"
assert by["openai-api"].tab == "keys"
# account / sign-in flows → accounts
assert by["google-gemini-cli"].tab == "accounts"
assert by["copilot-acp"].tab == "accounts"

View File

@ -489,14 +489,13 @@ def test_accounts_offers_every_oauth_provider_from_catalog():
)
def test_gemini_cli_and_copilot_acp_now_in_accounts():
"""Regression: google-gemini-cli and copilot-acp were canonical providers the
CLI could configure, but had no Accounts card (the reported GUI/CLI drift).
def test_copilot_acp_now_in_accounts():
"""Regression: copilot-acp was a canonical provider the CLI could configure,
but had no Accounts card (the reported GUI/CLI drift).
"""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "google-gemini-cli" in providers
assert "copilot-acp" in providers
# copilot-acp is managed by an external CLI: read-only card, not auto-removable.
assert providers["copilot-acp"]["flow"] == "external"

View File

@ -1,447 +0,0 @@
"""Regression tests for Google Workspace OAuth setup.
These tests cover the headless/manual auth-code flow where the browser step and
code exchange happen in separate process invocations.
"""
import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/setup.py"
)
class FakeCredentials:
def __init__(self, payload=None):
self._payload = payload or {
"token": "access-token",
"refresh_token": "refresh-token",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "client-id",
"client_secret": "client-secret",
"scopes": [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/documents.readonly",
],
}
def to_json(self):
return json.dumps(self._payload)
class FakeFlow:
created = []
default_state = "generated-state"
default_verifier = "generated-code-verifier"
credentials_payload = None
fetch_error = None
def __init__(
self,
client_secrets_file,
scopes,
*,
redirect_uri=None,
state=None,
code_verifier=None,
autogenerate_code_verifier=False,
):
self.client_secrets_file = client_secrets_file
self.scopes = scopes
self.redirect_uri = redirect_uri
self.state = state
self.code_verifier = code_verifier
self.autogenerate_code_verifier = autogenerate_code_verifier
self.authorization_kwargs = None
self.fetch_token_calls = []
self.credentials = FakeCredentials(self.credentials_payload)
if autogenerate_code_verifier and not self.code_verifier:
self.code_verifier = self.default_verifier
if not self.state:
self.state = self.default_state
@classmethod
def reset(cls):
cls.created = []
cls.default_state = "generated-state"
cls.default_verifier = "generated-code-verifier"
cls.credentials_payload = None
cls.fetch_error = None
@classmethod
def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs):
inst = cls(client_secrets_file, scopes, **kwargs)
cls.created.append(inst)
return inst
def authorization_url(self, **kwargs):
self.authorization_kwargs = kwargs
return f"https://auth.example/authorize?state={self.state}", self.state
def fetch_token(self, **kwargs):
self.fetch_token_calls.append(kwargs)
if self.fetch_error:
raise self.fetch_error
@pytest.fixture
def setup_module(monkeypatch, tmp_path):
FakeFlow.reset()
google_auth_module = types.ModuleType("google_auth_oauthlib")
flow_module = types.ModuleType("google_auth_oauthlib.flow")
flow_module.Flow = FakeFlow
google_auth_module.flow = flow_module
monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_auth_module)
monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", flow_module)
spec = importlib.util.spec_from_file_location("google_workspace_setup_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
monkeypatch.setattr(module, "_ensure_deps", lambda: None)
monkeypatch.setattr(module, "CLIENT_SECRET_PATH", tmp_path / "google_client_secret.json")
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
monkeypatch.setattr(module, "PENDING_AUTH_PATH", tmp_path / "google_oauth_pending.json", raising=False)
client_secret = {
"installed": {
"client_id": "client-id",
"client_secret": "client-secret",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret))
return module
class TestGetAuthUrl:
def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys):
setup_module.get_auth_url()
out = capsys.readouterr().out.strip()
assert out == "https://auth.example/authorize?state=generated-state"
saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text())
assert saved["state"] == "generated-state"
assert saved["code_verifier"] == "generated-code-verifier"
flow = FakeFlow.created[-1]
assert flow.autogenerate_code_verifier is True
assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"}
class TestExchangeAuthCode:
def test_reuses_saved_pkce_material_for_plain_code(self, setup_module):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.exchange_auth_code("4/test-auth-code")
flow = FakeFlow.created[-1]
assert flow.state == "saved-state"
assert flow.code_verifier == "saved-verifier"
assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}]
saved = json.loads(setup_module.TOKEN_PATH.read_text())
assert saved["token"] == "access-token"
assert saved["type"] == "authorized_user"
assert not setup_module.PENDING_AUTH_PATH.exists()
def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.exchange_auth_code(
"http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail"
)
flow = FakeFlow.created[-1]
assert flow.fetch_token_calls == [{"code": "4/extracted-code"}]
def test_passes_scopes_from_redirect_url_to_flow(self, setup_module):
"""Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES)."""
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
g1 = "https://www.googleapis.com/auth/gmail.readonly"
g2 = "https://www.googleapis.com/auth/calendar"
from urllib.parse import quote
scope_q = quote(f"{g1} {g2}", safe="")
setup_module.exchange_auth_code(
f"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}"
)
flow = FakeFlow.created[-1]
assert flow.scopes == [g1, g2]
def test_rejects_state_mismatch(self, setup_module, capsys):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
with pytest.raises(SystemExit):
setup_module.exchange_auth_code(
"http://localhost:1/?code=4/extracted-code&state=wrong-state"
)
out = capsys.readouterr().out
assert "state mismatch" in out.lower()
assert not setup_module.TOKEN_PATH.exists()
def test_requires_pending_auth_session(self, setup_module, capsys):
with pytest.raises(SystemExit):
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "run --auth-url first" in out.lower()
assert not setup_module.TOKEN_PATH.exists()
def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
FakeFlow.fetch_error = Exception("invalid_grant: Missing code verifier")
with pytest.raises(SystemExit):
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "token exchange failed" in out.lower()
assert setup_module.PENDING_AUTH_PATH.exists()
assert not setup_module.TOKEN_PATH.exists()
def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):
"""Partial scopes are accepted with a warning (gws migration: v2.0)."""
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES}))
FakeFlow.credentials_payload = {
"token": "***",
"refresh_token": "***",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "client-id",
"client_secret": "client-secret",
"scopes": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/spreadsheets",
],
}
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "warning" in out.lower()
assert "missing" in out.lower()
# Token is saved (partial scopes accepted)
assert setup_module.TOKEN_PATH.exists()
# Pending auth is cleaned up
assert not setup_module.PENDING_AUTH_PATH.exists()
class TestHermesConstantsFallback:
"""Tests for _hermes_home.py fallback when hermes_constants is unavailable."""
HELPER_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/_hermes_home.py"
)
def _load_helper(self, monkeypatch):
"""Load _hermes_home.py with hermes_constants blocked."""
monkeypatch.setitem(sys.modules, "hermes_constants", None)
spec = importlib.util.spec_from_file_location("_hermes_home_test", self.HELPER_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path):
"""When hermes_constants is missing, HERMES_HOME comes from env var."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == tmp_path / "custom-hermes"
def test_fallback_defaults_to_dot_hermes(self, monkeypatch):
"""When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes."""
monkeypatch.delenv("HERMES_HOME", raising=False)
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == Path.home() / ".hermes"
def test_fallback_ignores_empty_hermes_home(self, monkeypatch):
"""Empty/whitespace HERMES_HOME is treated as unset."""
monkeypatch.setenv("HERMES_HOME", " ")
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == Path.home() / ".hermes"
def test_fallback_display_hermes_home_shortens_path(self, monkeypatch):
"""Fallback display_hermes_home() uses ~/ shorthand like the real one."""
monkeypatch.delenv("HERMES_HOME", raising=False)
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "~/.hermes"
def test_fallback_display_hermes_home_profile_path(self, monkeypatch):
"""Fallback display_hermes_home() handles profile paths under ~/."""
monkeypatch.setenv("HERMES_HOME", str(Path.home() / ".hermes/profiles/coder"))
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "~/.hermes/profiles/coder"
def test_fallback_display_hermes_home_custom_path(self, monkeypatch):
"""Fallback display_hermes_home() returns full path for non-home locations."""
monkeypatch.setenv("HERMES_HOME", "/opt/hermes-custom")
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "/opt/hermes-custom"
def test_delegates_to_hermes_constants_when_available(self):
"""When hermes_constants IS importable, _hermes_home delegates to it."""
spec = importlib.util.spec_from_file_location(
"_hermes_home_happy", self.HELPER_PATH
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
import hermes_constants
assert module.get_hermes_home is hermes_constants.get_hermes_home
assert module.display_hermes_home is hermes_constants.display_hermes_home
def _load_setup_module(monkeypatch):
"""Load setup.py without stubbing _ensure_deps (for install_deps tests)."""
spec = importlib.util.spec_from_file_location(
"google_workspace_setup_installdeps_test", SCRIPT_PATH
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _force_deps_missing(monkeypatch):
"""Make `import googleapiclient` / `import google_auth_oauthlib` fail so
install_deps() proceeds past its early-return short-circuit."""
for name in ("googleapiclient", "google_auth_oauthlib"):
monkeypatch.setitem(sys.modules, name, None)
class TestInstallDeps:
"""Tests for install_deps() interpreter/installer selection.
Regression coverage for the Hermes Docker image, whose venv is built with
`uv sync` and ships without pip `sys.executable -m pip install` fails
with `No module named pip`, so install_deps() must fall back to uv.
"""
def test_returns_early_when_already_installed(self, monkeypatch):
"""If both libs import, no installer subprocess runs at all."""
module = _load_setup_module(monkeypatch)
# Don't force-missing: real test env has the libs importable. Guard
# against any subprocess being spawned.
calls = []
monkeypatch.setattr(
module.subprocess, "check_call", lambda *a, **k: calls.append(a)
)
# google_auth_oauthlib may not be installed in the test env; only run
# this assertion when the early-return path is actually reachable.
try:
import googleapiclient # noqa: F401
import google_auth_oauthlib # noqa: F401
except ImportError:
pytest.skip("Google libs not installed in test env")
assert module.install_deps() is True
assert calls == []
def test_uses_pip_when_available(self, monkeypatch):
"""When pip works, install_deps succeeds via pip and never calls uv."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
recorded = []
def fake_check_call(cmd, **kwargs):
recorded.append(cmd)
# pip path is the first attempt — succeed.
return 0
which_calls = []
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(
module.shutil, "which", lambda name: which_calls.append(name)
)
assert module.install_deps() is True
assert recorded[0][:3] == [module.sys.executable, "-m", "pip"]
# Control: uv must NOT be consulted when pip succeeds.
assert which_calls == []
def test_falls_back_to_uv_when_pip_missing(self, monkeypatch):
"""No pip → uv pip install --python <interpreter> is used."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
recorded = []
def fake_check_call(cmd, **kwargs):
recorded.append(cmd)
if cmd[:3] == [module.sys.executable, "-m", "pip"]:
raise module.subprocess.CalledProcessError(1, cmd)
return 0 # uv invocation succeeds
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
assert module.install_deps() is True
assert len(recorded) == 2
uv_cmd = recorded[1]
assert uv_cmd[0] == "/usr/local/bin/uv"
assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable]
for pkg in module.REQUIRED_PACKAGES:
assert pkg in uv_cmd
def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys):
"""No pip AND no uv → failure, with the [google] extra hint printed."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
def fake_check_call(cmd, **kwargs):
raise module.subprocess.CalledProcessError(1, cmd)
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: None)
assert module.install_deps() is False
out = capsys.readouterr().out
assert "hermes-agent[google]" in out
def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys):
"""uv present but its install fails → failure surfaced (not swallowed)."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
def fake_check_call(cmd, **kwargs):
raise module.subprocess.CalledProcessError(1, cmd)
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
assert module.install_deps() is False
out = capsys.readouterr().out
assert "via uv" in out

View File

@ -127,7 +127,7 @@ See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a tem
Use the full checklist below when your provider needs any of the following:
- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot)
- OAuth or token refresh (Nous Portal, Codex, Qwen Portal, Copilot)
- A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)
- Custom endpoint detection or multi-region probing (z.ai, Kimi)
- A curated static model catalog or live `/models` fetch

View File

@ -195,7 +195,7 @@ Set `profile.api_mode` to match the default your provider ships — it acts as a
|---|---|---|
| `api_key` | Single env var carries a static API key | Most providers |
| `oauth_device_code` | Device-code OAuth flow | — |
| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Gemini Cloud Code, Qwen Portal, Nous Portal |
| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Qwen Portal, Nous Portal |
| `copilot` | GitHub Copilot token refresh cycle | `copilot` plugin only |
| `aws_sdk` | AWS SDK credential chain (IAM role, profile, env) | `bedrock` plugin only |
| `external_process` | Auth handled by a subprocess the agent spawns | `copilot-acp` plugin only |

View File

@ -47,7 +47,7 @@ Current provider families include (see `plugins/model-providers/` for the comple
- OpenAI Codex
- Copilot / Copilot ACP
- Anthropic (native)
- Google / Gemini (`gemini`, `google-gemini-cli`, `google-antigravity`)
- Google / Gemini (`gemini`)
- Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)
- DeepSeek
- Z.AI

View File

@ -126,7 +126,6 @@ Good defaults:
| **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) |
| **Azure Foundry** | Azure AI Foundry-hosted models | Set `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |
| **Google AI Studio** | Gemini models via direct API | Set `GOOGLE_API_KEY` / `GEMINI_API_KEY` |
| **Google Gemini (OAuth)** | Gemini via the `google-gemini-cli` OAuth flow — no key needed | `hermes model` → Google Gemini (OAuth) |
| **xAI** | Grok models via direct API | Set `XAI_API_KEY` |
| **xAI Grok OAuth** | SuperGrok / Premium+ subscription, no API key needed | `hermes model` → xAI Grok OAuth |
| **NovitaAI** | Multi-model API gateway | Set `NOVITA_API_KEY` |

View File

@ -1,15 +1,13 @@
---
sidebar_position: 16
title: "Google Gemini"
description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, OAuth option, tool calling, streaming, and quota guidance"
description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, tool calling, streaming, and quota guidance"
---
# Google Gemini
Hermes Agent supports Google Gemini as a native provider using the **Google AI Studio / Gemini API** — not the OpenAI-compatible endpoint. This lets Hermes translate its internal OpenAI-shaped message and tool loop into Gemini's native `generateContent` API while preserving tool calling, streaming, multimodal inputs, and Gemini-specific response metadata.
Hermes also supports a separate **Google Gemini (OAuth)** provider that uses the same Cloud Code Assist backend as Google's Gemini CLI. Use the API-key provider (`gemini`) for the lowest-risk official API path.
## Prerequisites
- **Google AI Studio API key** — create one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
@ -100,30 +98,6 @@ If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or chang
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
```
### OAuth Provider
Hermes also has a `google-gemini-cli` provider:
```bash
hermes model
# → Choose "Google Gemini (OAuth)"
```
This uses browser PKCE login and the Cloud Code Assist backend. It can be useful for users who want Gemini CLI-style OAuth, but Hermes shows an explicit warning because Google may treat use of the Gemini CLI OAuth client from third-party software as a policy violation. For production or lowest-risk usage, prefer the API-key provider above.
Hermes also supports `google-antigravity` for Antigravity Code Assist:
```bash
hermes model
# → Choose "Google Antigravity (OAuth)"
```
That provider uses a separate Antigravity OAuth login and stores separate
credentials at `~/.hermes/auth/antigravity_oauth.json`. Its model picker uses
live Antigravity model discovery, so the list reflects the signed-in account's
subscription and can include Antigravity-only Gemini agent models plus other
entitled model families.
## Available Models
The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include:
@ -205,18 +179,8 @@ hermes doctor
The doctor checks:
- Whether `GOOGLE_API_KEY` or `GEMINI_API_KEY` is available
- Whether Gemini OAuth credentials exist for `google-gemini-cli`
- Whether Antigravity OAuth credentials exist for `google-antigravity`
- Whether configured provider credentials can be resolved
For OAuth quota usage, run this inside a Hermes session:
```text
/gquota
```
`/gquota` applies to the `google-gemini-cli` OAuth provider, not the AI Studio API-key provider.
## Gateway (Messaging Platforms)
Gemini works with all Hermes gateway platforms (Telegram, Discord, Slack, WhatsApp, LINE, Feishu, etc.). Configure Gemini as your provider, then start the gateway normally:
@ -278,10 +242,6 @@ Change it to the native endpoint or remove the override:
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
```
### OAuth login warning
The `google-gemini-cli` provider uses a Gemini CLI / Cloud Code Assist OAuth flow. Hermes warns before starting it because this is distinct from the official AI Studio API-key path. Use `provider: gemini` with `GOOGLE_API_KEY` for the official API-key integration.
### Tool calling fails with schema errors
Upgrade Hermes and rerun `hermes model`. The native Gemini adapter sanitizes tool schemas for Gemini's stricter function-declaration format; older builds or custom endpoints may not.

View File

@ -40,7 +40,6 @@ 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 Gemini (OAuth)** | `hermes model` → "Google Gemini (OAuth)" (provider: `google-gemini-cli`, free tier supported, browser PKCE login) |
| **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) |
@ -49,7 +48,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **Qwen OAuth** | `hermes model` → "Qwen OAuth" (provider: `qwen-oauth`; browser PKCE login) |
| **MiniMax OAuth** | `hermes model` → "MiniMax (OAuth)" (provider: `minimax-oauth`; browser PKCE login) |
| **StepFun** | `STEPFUN_API_KEY` in `~/.hermes/.env` (provider: `stepfun`) |
| **Google Antigravity (OAuth)** | `hermes model` → "Google Antigravity (OAuth)" (provider: `google-antigravity`, aliases: `antigravity`, `antigravity-oauth`, `agy`) |
| **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) |
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
@ -79,64 +77,6 @@ Don't have a subscription yet? Get one at [portal.nousresearch.com/manage-subscr
**JWT auth (automatic).** Hermes prefers scoped `inference:invoke` JWTs for Portal requests with the legacy opaque session-key path as a fallback. No configuration is required — credentials are managed by the OAuth flow and rotate transparently. Revoked refresh tokens are quarantined to avoid replay loops.
### Google Antigravity via OAuth (`google-antigravity`)
The `google-antigravity` provider uses Antigravity's Code Assist backend and
Antigravity OAuth scopes. It is a native Hermes integration: Hermes runs its
own browser PKCE login, stores credentials under
`~/.hermes/auth/antigravity_oauth.json`, and talks directly to the Antigravity
Code Assist endpoints. It does not shell out to `agy` for inference, and it
does not depend on the Antigravity CLI's local token storage.
**Quick start:**
```bash
hermes model
# -> pick "Google Antigravity (OAuth)"
# -> browser opens to accounts.google.com, sign in
# -> pick one of the models available to your Antigravity account
```
Hermes discovers Antigravity models from `fetchAvailableModels` after login.
The visible list depends on the authenticated account and subscription, and can
include Antigravity-only Gemini agent models plus Claude and GPT-OSS entries
when the account is entitled. If live discovery fails, Hermes falls back to a
small curated list so the provider remains selectable.
Supported aliases:
```text
google-antigravity
google-antigravity-oauth
antigravity
antigravity-oauth
antigravity-cli
agy
agy-cli
```
Optional overrides:
```bash
HERMES_ANTIGRAVITY_CLIENT_ID=your-client.apps.googleusercontent.com
HERMES_ANTIGRAVITY_CLIENT_SECRET=...
HERMES_ANTIGRAVITY_CLI_PATH=/path/to/agy
HERMES_ANTIGRAVITY_PROJECT_ID=your-project
```
If the client ID/secret are not set explicitly, Hermes tries to discover the
desktop OAuth client credentials from the installed Antigravity CLI (`agy`) on
`PATH`, `HERMES_ANTIGRAVITY_CLI_PATH`, or common Antigravity install/cache
locations. Those client credentials are used only to start and refresh Hermes'
own OAuth session; Hermes still keeps its access/refresh tokens in `~/.hermes`.
:::note Windows credential storage
The Antigravity CLI may keep its own login in platform-specific storage such as
Windows Credential Manager. Hermes intentionally keeps separate credentials in
`~/.hermes` so development profiles and production Hermes profiles do not share
tokens accidentally.
:::
:::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.
@ -592,91 +532,6 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest
The base URL can be overridden with `HF_BASE_URL`.
### Google Gemini via OAuth (`google-gemini-cli`)
The `google-gemini-cli` provider uses Google's Cloud Code Assist backend — the
same API that Google's own `gemini-cli` tool uses. This supports both the
**free tier** (generous daily quota for personal accounts) and **paid tiers**
(Standard/Enterprise via a GCP project).
**Quick start:**
```bash
hermes model
# → pick "Google Gemini (OAuth)"
# → see policy warning, confirm
# → browser opens to accounts.google.com, sign in
# → done — Hermes auto-provisions your free tier on first request
```
Hermes ships Google's **public** `gemini-cli` desktop OAuth client by default —
the same credentials Google includes in their open-source `gemini-cli`. Desktop
OAuth clients are not confidential (PKCE provides the security). You do not
need to install `gemini-cli` or register your own GCP OAuth client.
**How auth works:**
- PKCE Authorization Code flow against `accounts.google.com`
- Browser callback at `http://127.0.0.1:8085/oauth2callback` (with ephemeral-port fallback if busy)
- Tokens stored at `~/.hermes/auth/google_oauth.json` (chmod 0600, atomic write, cross-process `fcntl` lock)
- Automatic refresh 60 s before expiry
- Headless environments (SSH, `HERMES_HEADLESS=1`) → paste-mode fallback
- Inflight refresh deduplication — two concurrent requests won't double-refresh
- `invalid_grant` (revoked refresh) → credential file wiped, user prompted to re-login
**How inference works:**
- Traffic goes to `https://cloudcode-pa.googleapis.com/v1internal:generateContent`
(or `:streamGenerateContent?alt=sse` for streaming), NOT the paid `v1beta/openai` endpoint
- Request body wrapped `{project, model, user_prompt_id, request}`
- OpenAI-shaped `messages[]`, `tools[]`, `tool_choice` are translated to Gemini's native
`contents[]`, `tools[].functionDeclarations`, `toolConfig` shape
- Responses translated back to OpenAI shape so the rest of Hermes works unchanged
**Tiers & project IDs:**
| Your situation | What to do |
|---|---|
| Personal Google account, want free tier | Nothing — sign in, start chatting |
| Workspace / Standard / Enterprise account | Set `HERMES_GEMINI_PROJECT_ID` or `GOOGLE_CLOUD_PROJECT` to your GCP project ID |
| VPC-SC-protected org | Hermes detects `SECURITY_POLICY_VIOLATED` and forces `standard-tier` automatically |
Free tier auto-provisions a Google-managed project on first use. No GCP setup required.
**Quota monitoring:**
```
/gquota
```
Shows remaining Code Assist quota per model with progress bars:
```
Gemini Code Assist quota (project: 123-abc)
gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85%
gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92%
```
:::warning Policy risk
Google considers using the Gemini CLI OAuth client with third-party software a
policy violation. Some users have reported account restrictions. For the lowest-risk
experience, use your own API key via the `gemini` provider instead. Hermes shows
an upfront warning and requires explicit confirmation before OAuth begins.
:::
**Custom OAuth client (optional):**
If you'd rather register your own Google OAuth client — e.g., to keep quota
and consent scoped to your own GCP project — set:
```bash
HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com
HERMES_GEMINI_CLIENT_SECRET=... # optional for Desktop clients
```
Register a **Desktop app** OAuth client at
[console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
with the Generative Language API enabled.
## Custom & Self-Hosted LLM Providers
Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API.
@ -1591,7 +1446,7 @@ fallback_model:
When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session.
Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
:::tip
Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/user-guide/features/fallback-providers).

View File

@ -100,7 +100,7 @@ Common options:
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
| `-m`, `--model <model>` | Override the model for this run. |
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity` (aliases: `antigravity`, `antigravity-oauth`, `agy`), `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
| `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). |
| `-v`, `--verbose` | Verbose output. |
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |

View File

@ -67,13 +67,6 @@ Hermes reads environment variables from the process environment and, for user-ma
| `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_BASE_URL` | Override Google AI Studio base URL |
| `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (optional; defaults to Google's public gemini-cli client) |
| `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional) |
| `HERMES_GEMINI_PROJECT_ID` | GCP project ID for paid Gemini tiers (free tier auto-provisions) |
| `HERMES_ANTIGRAVITY_CLIENT_ID` | OAuth client ID for `google-antigravity` PKCE login (optional; discovered from installed `agy` when omitted) |
| `HERMES_ANTIGRAVITY_CLIENT_SECRET` | OAuth client secret for `google-antigravity` (optional; discovered from installed `agy` when omitted) |
| `HERMES_ANTIGRAVITY_CLI_PATH` | Path to the `agy` executable or install file used for Antigravity OAuth client credential discovery |
| `HERMES_ANTIGRAVITY_PROJECT_ID` | GCP project ID for Antigravity Code Assist when you want to pin one explicitly |
| `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |
| `ANTHROPIC_BASE_URL` | Override the Anthropic API base URL |
| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |

View File

@ -20,7 +20,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include:
- **[Nous Portal](/integrations/nous-portal)** — Nous Research's subscription gateway — 300+ models plus web/image/TTS/browser through one OAuth login (recommended for newcomers)
- **OpenAI** — GPT-5.4, GPT-5-codex, GPT-4.1, GPT-4o, etc.
- **Anthropic** — Claude models (direct API, OAuth via `hermes auth add anthropic`, OpenRouter, or any compatible proxy)
- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy)
- **Google** — Gemini models (direct API via `gemini` provider, OpenRouter, or compatible proxy)
- **z.ai / ZhipuAI** — GLM models
- **Kimi / Moonshot AI** — Kimi models
- **MiniMax** — global and China endpoints

View File

@ -115,7 +115,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/image <path>` | Attach a local image file for your next prompt. |
| `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |
| `/profile` | Show active profile name and home directory |
| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). |
### Exit
@ -246,7 +245,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
## Notes
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.

View File

@ -959,7 +959,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
:::tip MiniMax OAuth
`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md).

View File

@ -62,8 +62,6 @@ Each entry requires both `provider` and `model`. Entries missing either field ar
| GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) |
| StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) |
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
| Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) |
| Google Antigravity (OAuth) | `google-antigravity` | `hermes model` (Antigravity OAuth; optional: `HERMES_ANTIGRAVITY_PROJECT_ID`) |
| Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) |
| xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) |
| xAI Grok OAuth (SuperGrok) | `xai-oauth` (alias `grok-oauth`) | `hermes model` → xAI Grok OAuth (browser login; SuperGrok subscription) |

View File

@ -343,7 +343,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer
/commands [page] Browse all commands (gateway)
/usage Token usage
/insights [days] Usage analytics
/gquota Show Google Gemini Code Assist quota usage (CLI)
/status Session info (gateway)
/profile Active profile info
/debug Upload debug report (system info + logs) and get shareable links

View File

@ -127,7 +127,7 @@ Hermes 已经可以通过自定义 provider 路径与任何 OpenAI 兼容的端
当你的 provider 需要以下任何内容时,使用下面的完整清单:
- OAuth 或 token 刷新Nous Portal、Codex、Google Gemini、Qwen Portal、Copilot
- OAuth 或 token 刷新Nous Portal、Codex、Qwen Portal、Copilot
- 需要新适配器的非 OpenAI API 格式Anthropic Messages、Codex Responses
- 自定义端点检测或多区域探测z.ai、Kimi
- 精选的静态模型目录或实时 `/models` 获取

View File

@ -194,7 +194,7 @@ register_provider(ProviderProfile(
|---|---|---|
| `api_key` | 单个环境变量携带静态 API key | 大多数提供商 |
| `oauth_device_code` | 设备码 OAuth 流程 | — |
| `oauth_external` | 用户在其他地方登录token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Gemini Cloud Code、Qwen Portal、Nous Portal |
| `oauth_external` | 用户在其他地方登录token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Qwen Portal、Nous Portal |
| `copilot` | GitHub Copilot token 刷新周期 | 仅 `copilot` 插件 |
| `aws_sdk` | AWS SDK 凭据链IAM role、profile、env | 仅 `bedrock` 插件 |
| `external_process` | 认证由 agent 启动的子进程处理 | 仅 `copilot-acp` 插件 |

View File

@ -47,7 +47,7 @@ Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景:
- OpenAI Codex
- Copilot / Copilot ACP
- Anthropic原生
- Google / Gemini`gemini`、`google-gemini-cli`
- Google / Gemini`gemini`
- Alibaba / DashScope`alibaba`、`alibaba-coding-plan`
- DeepSeek
- Z.AI

View File

@ -1,15 +1,13 @@
---
sidebar_position: 16
title: "Google Gemini"
description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、OAuth 选项、工具调用、流式传输及配额说明"
description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、工具调用、流式传输及配额说明"
---
# Google Gemini
Hermes Agent 通过 **Google AI Studio / Gemini API** 原生支持 Google Gemini——而非 OpenAI 兼容端点。这使 Hermes 能够将其内部 OpenAI 格式的消息和工具循环转换为 Gemini 原生的 `generateContent` API同时保留工具调用、流式传输、多模态输入以及 Gemini 特有的响应元数据。
Hermes 还支持独立的 **Google GeminiOAuth** provider使用与 Google Gemini CLI 相同的 Cloud Code Assist 后端。如需最低风险的官方 API 路径,请使用 API 密钥 provider`gemini`)。
## 前提条件
- **Google AI Studio API 密钥** — 在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建
@ -100,17 +98,6 @@ https://generativelanguage.googleapis.com/v1beta/openai/
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
```
### OAuth Provider
Hermes 还提供 `google-gemini-cli` provider
```bash
hermes model
# → 选择 "Google Gemini (OAuth)"
```
该方式使用浏览器 PKCE 登录和 Cloud Code Assist 后端。对于希望使用 Gemini CLI 风格 OAuth 的用户可能有用,但 Hermes 会显示明确警告,因为 Google 可能将第三方软件使用 Gemini CLI OAuth 客户端的行为视为违反政策。对于生产环境或最低风险使用场景,请优先使用上述 API 密钥 provider。
## 可用模型
`hermes model` 选择器显示 Hermes provider 注册表中维护的 Gemini 模型。常见选项包括:
@ -192,17 +179,8 @@ hermes doctor
doctor 命令检查:
- `GOOGLE_API_KEY``GEMINI_API_KEY` 是否可用
- `google-gemini-cli` 的 Gemini OAuth 凭据是否存在
- 已配置的 provider 凭据是否可以解析
如需查看 OAuth 配额使用情况,请在 Hermes 会话中运行:
```text
/gquota
```
`/gquota` 适用于 `google-gemini-cli` OAuth provider不适用于 AI Studio API 密钥 provider。
## Gateway消息平台
Gemini 可与所有 Hermes gateway 平台配合使用Telegram、Discord、Slack、WhatsApp、LINE、飞书等。将 Gemini 配置为你的 provider然后正常启动 gateway
@ -264,10 +242,6 @@ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
```
### OAuth 登录警告
`google-gemini-cli` provider 使用 Gemini CLI / Cloud Code Assist OAuth 流程。Hermes 在启动前会发出警告,因为这与官方 AI Studio API 密钥路径不同。如需官方 API 密钥集成,请使用 `provider: gemini` 配合 `GOOGLE_API_KEY`
### 工具调用因 schema 错误而失败
升级 Hermes 并重新运行 `hermes model`。原生 Gemini 适配器会针对 Gemini 更严格的函数声明格式对工具 schema 进行清理;旧版本或自定义端点可能不支持此功能。

View File

@ -40,7 +40,6 @@ sidebar_position: 1
| **DeepSeek** | `~/.hermes/.env` 中的 `DEEPSEEK_API_KEY`provider: `deepseek` |
| **Hugging Face** | `~/.hermes/.env` 中的 `HF_TOKEN`provider: `huggingface`,别名:`hf` |
| **Google / Gemini** | `~/.hermes/.env` 中的 `GOOGLE_API_KEY`(或 `GEMINI_API_KEY`provider: `gemini` |
| **Google GeminiOAuth** | `hermes model` → "Google Gemini (OAuth)"provider: `google-gemini-cli`,支持免费层,浏览器 PKCE 登录) |
| **LM Studio** | `hermes model` → "LM Studio"provider: `lmstudio`,可选 `LM_API_KEY` |
| **自定义端点** | `hermes model` → 选择"Custom endpoint"(保存在 `config.yaml` |
@ -512,79 +511,6 @@ model:
基础 URL 可通过 `HF_BASE_URL` 覆盖。
### 通过 OAuth 使用 Google Gemini`google-gemini-cli`
`google-gemini-cli` 提供商使用 Google 的 Cloud Code Assist 后端——与 Google 自己的 `gemini-cli` 工具使用的 API 相同。支持**免费层**(个人账户每日配额充足)和**付费层**(通过 GCP 项目的 Standard/Enterprise
**快速开始:**
```bash
hermes model
# → 选择"Google Gemini (OAuth)"
# → 查看政策警告,确认
# → 浏览器打开 accounts.google.com登录
# → 完成——Hermes 在首次请求时自动开通免费层
```
Hermes 默认使用 Google 的**公开** `gemini-cli` 桌面 OAuth 客户端——与 Google 在其开源 `gemini-cli` 中包含的凭据相同。桌面 OAuth 客户端不是机密客户端PKCE 提供安全保障)。你无需安装 `gemini-cli` 或注册自己的 GCP OAuth 客户端。
**认证工作原理:**
- 针对 `accounts.google.com` 的 PKCE 授权码流程
- 浏览器回调地址 `http://127.0.0.1:8085/oauth2callback`(端口占用时自动回退到临时端口)
- Token 存储在 `~/.hermes/auth/google_oauth.json`chmod 0600原子写入跨进程 `fcntl` 锁)
- 到期前 60 秒自动刷新
- 无头环境SSH、`HERMES_HEADLESS=1`)→ 粘贴模式回退
- 并发刷新去重——两个并发请求不会触发双重刷新
- `invalid_grant`(刷新 token 被撤销)→ 凭据文件被清除,提示用户重新登录
**推理工作原理:**
- 流量发送到 `https://cloudcode-pa.googleapis.com/v1internal:generateContent`
(流式传输为 `:streamGenerateContent?alt=sse`),而非付费的 `v1beta/openai` 端点
- 请求体封装为 `{project, model, user_prompt_id, request}`
- OpenAI 格式的 `messages[]`、`tools[]`、`tool_choice` 被转换为 Gemini 原生的
`contents[]`、`tools[].functionDeclarations`、`toolConfig` 格式
- 响应转换回 OpenAI 格式Hermes 其余部分无感知
**层级与项目 ID**
| 你的情况 | 操作 |
|---|---|
| 个人 Google 账户,使用免费层 | 无需操作——登录即可开始聊天 |
| Workspace / Standard / Enterprise 账户 | 将 `HERMES_GEMINI_PROJECT_ID``GOOGLE_CLOUD_PROJECT` 设置为你的 GCP 项目 ID |
| VPC-SC 保护的组织 | Hermes 检测到 `SECURITY_POLICY_VIOLATED` 后自动强制使用 `standard-tier` |
免费层在首次使用时自动开通 Google 托管项目。无需 GCP 配置。
**配额监控:**
```
/gquota
```
以进度条显示每个模型的剩余 Code Assist 配额:
```
Gemini Code Assist quota (project: 123-abc)
gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85%
gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92%
```
:::warning 政策风险
Google 认为将 Gemini CLI OAuth 客户端用于第三方软件违反政策。部分用户反映账户受到限制。为降低风险,建议改用 `gemini` 提供商并通过 API key 访问。Hermes 会在 OAuth 开始前显示警告并要求明确确认。
:::
**自定义 OAuth 客户端(可选):**
如果你希望注册自己的 Google OAuth 客户端——例如将配额和授权范围限定在自己的 GCP 项目内——请设置:
```bash
HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com
HERMES_GEMINI_CLIENT_SECRET=... # 桌面客户端可选
```
在 [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) 注册一个**桌面应用** OAuth 客户端,并启用 Generative Language API。
## 自定义与自托管 LLM 提供商
Hermes Agent 可与**任何 OpenAI 兼容 API 端点**配合使用。只要服务器实现了 `/v1/chat/completions`,就可以将 Hermes 指向它。这意味着你可以使用本地模型、GPU 推理服务器、多提供商路由器或任何第三方 API。
@ -1477,7 +1403,7 @@ fallback_model:
激活时,故障转移在不丢失对话的情况下中途切换模型和提供商。链按条目逐一尝试;每个会话激活一次。
支持的提供商:`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。
支持的提供商:`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。
:::tip
故障转移仅通过 `config.yaml` 配置——或通过 `hermes fallback` 交互式配置。有关触发时机、链推进方式以及与辅助任务和委托的交互,参见[故障转移提供商](/user-guide/features/fallback-providers)。

View File

@ -95,7 +95,7 @@ hermes chat [options]
| `-q`, `--query "..."` | 单次非交互式 prompt。 |
| `-m`, `--model <model>` | 覆盖本次运行的模型。 |
| `-t`, `--toolsets <csv>` | 启用逗号分隔的 toolset 集合。 |
| `--provider <provider>` | 强制指定 provider`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`google-gemini-cli`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
| `--provider <provider>` | 强制指定 provider`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
| `-s`, `--skills <name>` | 为会话预加载一个或多个 skill可重复或逗号分隔。 |
| `-v`, `--verbose` | 详细输出。 |
| `-Q`, `--quiet` | 程序化模式:抑制横幅/spinner/工具预览。 |

View File

@ -63,9 +63,6 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
| `GOOGLE_API_KEY` | Google AI Studio API 密钥([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) |
| `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 |
| `GEMINI_BASE_URL` | 覆盖 Google AI Studio base URL |
| `HERMES_GEMINI_CLIENT_ID` | `google-gemini-cli` PKCE 登录的 OAuth 客户端 ID可选默认使用 Google 公共 gemini-cli 客户端) |
| `HERMES_GEMINI_CLIENT_SECRET` | `google-gemini-cli` 的 OAuth 客户端密钥(可选) |
| `HERMES_GEMINI_PROJECT_ID` | 付费 Gemini 层级的 GCP 项目 ID免费层级自动配置 |
| `ANTHROPIC_API_KEY` | Anthropic Console API 密钥([console.anthropic.com](https://console.anthropic.com/) |
| `ANTHROPIC_TOKEN` | 手动或旧版 Anthropic OAuth/setup-token 覆盖 |
| `DASHSCOPE_API_KEY` | Qwen Cloud阿里巴巴 DashScopeQwen 模型 API 密钥([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/) |

View File

@ -20,7 +20,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商
- **Nous Portal** — Nous Research 自有推理端点
- **OpenAI** — GPT-5.4、GPT-5-codex、GPT-4.1、GPT-4o 等
- **Anthropic** — Claude 模型(直接 API、通过 `hermes auth add anthropic` 进行 OAuth、OpenRouter 或任何兼容代理)
- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、`google-gemini-cli` OAuth 提供商、OpenRouter 或兼容代理)
- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、OpenRouter 或兼容代理)
- **z.ai / ZhipuAI** — GLM 模型
- **Kimi / Moonshot AI** — Kimi 模型
- **MiniMax** — 全球及中国区端点

View File

@ -115,7 +115,6 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中
| `/image <path>` | 为下一条 prompt 附加本地图片文件。 |
| `/debug` | 上传调试报告(系统信息 + 日志)并获取可分享链接。消息平台中也可用。 |
| `/profile` | 显示活动 profile 名称和主目录 |
| `/gquota` | 以进度条形式显示 Google Gemini Code Assist 配额用量(仅在 `google-gemini-cli` 提供商激活时可用)。 |
### 退出
@ -246,7 +245,7 @@ hermes config set model.aliases.grok x-ai/grok-4
## 注意事项
- `/skin`、`/snapshot`、`/gquota`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
- `/skills` **仅在搜索/浏览/安装时属于 CLI-only**;其写入审批子命令(`pending`、`approve`、`reject`、`diff`、`approval`)在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。
- `/verbose` **默认仅限 CLI**,但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后,它会循环切换 `display.tool_progress` 模式并保存到配置。
- `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。

View File

@ -774,7 +774,7 @@ Hermes 中的每个模型槽位 —— 辅助任务、压缩、回退 —— 使
当设置 `base_url`Hermes 忽略 provider 并直接调用该端点(使用 `api_key``OPENAI_API_KEY` 进行认证)。当仅设置 `provider`Hermes 使用该 provider 的内置认证和基础 URL。
辅助任务的可用 providers`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider例如 `provider: "beans"`)。
辅助任务的可用 providers`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider例如 `provider: "beans"`)。
:::tip MiniMax OAuth
`minimax-oauth` 通过浏览器 OAuth 登录(无需 API 密钥)。运行 `hermes model` 并选择 **MiniMax (OAuth)** 进行认证。辅助任务自动使用 `MiniMax-M2.7-highspeed`。参阅 [MiniMax OAuth 指南](../guides/minimax-oauth.md)。

View File

@ -62,7 +62,6 @@ fallback_model:
| GMI Cloud | `gmi` | `GMI_API_KEY`(可选:`GMI_BASE_URL` |
| StepFun | `stepfun` | `STEPFUN_API_KEY`(可选:`STEPFUN_BASE_URL` |
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
| Google GeminiOAuth | `google-gemini-cli` | `hermes model`Google OAuth可选`HERMES_GEMINI_PROJECT_ID` |
| Google AI Studio | `gemini` | `GOOGLE_API_KEY`(别名:`GEMINI_API_KEY` |
| xAIGrok | `xai`(别名 `grok` | `XAI_API_KEY`(可选:`XAI_BASE_URL` |
| xAI Grok OAuthSuperGrok | `xai-oauth`(别名 `grok-oauth` | `hermes model` → xAI Grok OAuth浏览器登录需 SuperGrok 订阅) |

View File

@ -332,7 +332,6 @@ hermes uninstall Uninstall Hermes
/commands [page] Browse all commands (gateway)
/usage Token usage
/insights [days] Usage analytics
/gquota Show Google Gemini Code Assist quota usage (CLI)
/status Session info (gateway)
/profile Active profile info
/debug Upload debug report (system info + logs) and get shareable links