feat(cli): add native Antigravity OAuth provider

This commit is contained in:
pmos69 2026-05-20 21:18:04 +01:00 committed by Teknium
parent 29176ffecf
commit 8baa4e9976
25 changed files with 2371 additions and 18 deletions

View file

@ -1394,6 +1394,21 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
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

@ -0,0 +1,164 @@
"""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

@ -0,0 +1,276 @@
"""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)
return ProjectContext(
project_id=info.project_id,
managed_project_id=info.project_id,
source="discovered" if info.project_id else "unknown",
)
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)

872
agent/antigravity_oauth.py Normal file
View file

@ -0,0 +1,872 @@
"""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"
_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()
return discovered
def _get_client_secret() -> str:
env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
if env_val:
return env_val
_, discovered = _discover_client_credentials()
return discovered
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")
if isinstance(cached, list):
return [
(str(client_id), str(client_secret))
for client_id, client_secret in cached
if client_id and client_secret
]
client_id = str(_discovered_creds_cache.get("client_id") or "")
client_secret = str(_discovered_creds_cache.get("client_secret") or "")
return [(client_id, client_secret)] if client_id and client_secret else []
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

@ -93,11 +93,14 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
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": {
"name": fn.get("name") or "",
"args": args,
},
"functionCall": function_call,
# Sentinel signature — matches opencode-gemini-auth's approach.
# Without this, Code Assist rejects function calls that originated
# outside its own chain.
@ -122,12 +125,13 @@ def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
except json.JSONDecodeError:
parsed = None
response = parsed if isinstance(parsed, dict) else {"output": content}
return {
"functionResponse": {
"name": name,
"response": response,
},
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(
@ -358,8 +362,9 @@ def _translate_gemini_response(
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=f"call_{uuid.uuid4().hex[:12]}",
id=call_id,
type="function",
index=i,
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
@ -554,6 +559,7 @@ def _translate_stream_event(
model=model,
tool_call_delta={
"index": idx,
"id": str(fc.get("id") or "").strip(),
"name": name,
"arguments": args_str,
},

View file

@ -437,7 +437,7 @@ class ChatCompletionsTransport(ProviderTransport):
extra_body["extra_body"] = openai_compat_extra
elif raw_thinking_config:
extra_body["thinking_config"] = raw_thinking_config
elif provider_name == "google-gemini-cli":
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

View file

@ -142,6 +142,9 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = {
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
@ -212,6 +215,12 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
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",
@ -1530,6 +1539,7 @@ def resolve_provider(
"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",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
@ -2246,6 +2256,72 @@ def get_gemini_oauth_auth_status() -> Dict[str, Any]:
"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
# =============================================================================
@ -6191,6 +6267,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
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", "minimax-oauth"}
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "google-antigravity", "minimax-oauth"}
def _get_custom_provider_names() -> list:
@ -386,6 +386,27 @@ def auth_add_command(args) -> None:
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

@ -3100,6 +3100,38 @@ OPTIONAL_ENV_VARS = {
"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

@ -3074,6 +3074,8 @@ def select_provider_and_model(args=None):
_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":
@ -3609,6 +3611,271 @@ _DEFAULT_QWEN_PORTAL_MODELS = [
def _model_flow_google_antigravity(_config, current_model=""):
"""Google Antigravity OAuth via Antigravity Code Assist."""
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)
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.
Automatically saves the endpoint to ``custom_providers`` in config.yaml
so it appears in the provider menu on subsequent runs.
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import get_env_value, load_config, save_config
current_url = get_env_value("OPENAI_BASE_URL") or ""
current_key = get_env_value("OPENAI_API_KEY") or ""
print("Custom OpenAI-compatible endpoint configuration:")
if current_url:
print(f" Current URL: {current_url}")
if current_key:
print(f" Current key: {current_key[:8]}...")
print()
try:
base_url = input(
f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: "
).strip()
import getpass
api_key = getpass.getpass(
f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: "
).strip()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return
if not base_url and not current_url:
print("No URL provided. Cancelled.")
return
# Validate URL format
effective_url = base_url or current_url
if not effective_url.startswith(("http://", "https://")):
print(f"Invalid URL: {effective_url} (must start with http:// or https://)")
return
effective_key = api_key or current_key
# Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1
# in the base URL for OpenAI-compatible chat completions. Prompt the
# user if the URL looks like a local server without /v1.
_url_lower = effective_url.rstrip("/").lower()
_looks_local = any(
h in _url_lower
for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000")
)
if _looks_local and not _url_lower.endswith("/v1"):
print()
print(f" Hint: Did you mean to add /v1 at the end?")
print(f" Most local model servers (Ollama, vLLM, llama.cpp) require it.")
print(f" e.g. {effective_url.rstrip('/')}/v1")
try:
_add_v1 = input(" Add /v1? [Y/n]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
_add_v1 = "n"
if _add_v1 in {"", "y", "yes"}:
effective_url = effective_url.rstrip("/") + "/v1"
if base_url:
base_url = effective_url
print(f" Updated URL: {effective_url}")
print()
from hermes_cli.models import probe_api_models
probe = probe_api_models(effective_key, effective_url)
if probe.get("used_fallback") and probe.get("resolved_base_url"):
print(
f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, "
f"not the exact URL you entered. Saving the working base URL instead."
)
effective_url = probe["resolved_base_url"]
if base_url:
base_url = effective_url
elif probe.get("models") is not None:
print(
f"Verified endpoint via {probe.get('probed_url')} "
f"({len(probe.get('models') or [])} model(s) visible)"
)
else:
print(
f"Warning: could not verify this endpoint via {probe.get('probed_url')}. "
f"Hermes will still save it."
)
if probe.get("suggested_base_url"):
suggested = probe["suggested_base_url"]
if suggested.endswith("/v1"):
print(
f" If this server expects /v1 in the path, try base URL: {suggested}"
)
else:
print(f" If /v1 should not be in the base URL, try: {suggested}")
# Prompt for API compatibility mode explicitly so codex-compatible custom
# providers don't silently fall back to chat_completions.
current_model_cfg = config.get("model")
current_api_mode = ""
if isinstance(current_model_cfg, dict):
current_api_mode = str(current_model_cfg.get("api_mode") or "").strip()
api_mode = _prompt_custom_api_mode_selection(
effective_url,
current_api_mode=current_api_mode,
)
if api_mode:
print(f" API mode: {api_mode}")
else:
print(" API mode: auto-detect")
# Select model — use probe results when available, fall back to manual input
model_name = ""
detected_models = probe.get("models") or []
try:
if len(detected_models) == 1:
print(f" Detected model: {detected_models[0]}")
confirm = input(" Use this model? [Y/n]: ").strip().lower()
if confirm in {"", "y", "yes"}:
model_name = detected_models[0]
else:
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
elif len(detected_models) > 1:
print(" Available models:")
for i, m in enumerate(detected_models, 1):
print(f" {i}. {m}")
pick = input(
f" Select model [1-{len(detected_models)}] or type name: "
).strip()
if pick.isdigit() and 1 <= int(pick) <= len(detected_models):
model_name = detected_models[int(pick) - 1]
elif pick:
model_name = pick
else:
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
context_length_str = input(
"Context length in tokens [leave blank for auto-detect]: "
).strip()
# Prompt for a display name — shown in the provider menu on future runs
default_name = _auto_provider_name(effective_url)
display_name = input(f"Display name [{default_name}]: ").strip() or default_name
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return
context_length = None
if context_length_str:
try:
context_length = int(
context_length_str.replace(",", "")
.replace("k", "000")
.replace("K", "000")
)
if context_length <= 0:
context_length = None
except ValueError:
print(f"Invalid context length: {context_length_str} — will auto-detect.")
context_length = None
if model_name:
_save_model_choice(model_name)
# Update config and deactivate any OAuth provider
cfg = load_config()
model = cfg.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["provider"] = "custom"
model["base_url"] = effective_url
if effective_key:
model["api_key"] = effective_key
if api_mode:
model["api_mode"] = api_mode
else:
model.pop("api_mode", None)
save_config(cfg)
deactivate_provider()
# Sync the caller's config dict so the setup wizard's final
# save_config(config) preserves our model settings. Without
# this, the wizard overwrites model.provider/base_url with
# the stale values from its own config dict (#4172).
config["model"] = dict(model)
print(f"Default model set to: {model_name} (via {effective_url})")
else:
if base_url or api_key:
deactivate_provider()
# Even without a model name, persist the custom endpoint on the
# caller's config dict so the setup wizard doesn't lose it.
_caller_model = config.get("model")
if not isinstance(_caller_model, dict):
_caller_model = {"default": _caller_model} if _caller_model else {}
_caller_model["provider"] = "custom"
_caller_model["base_url"] = effective_url
if effective_key:
_caller_model["api_key"] = effective_key
if api_mode:
_caller_model["api_mode"] = api_mode
else:
_caller_model.pop("api_mode", None)
config["model"] = _caller_model
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
# Auto-save to custom_providers so it appears in the menu next time
_save_custom_provider(
effective_url,
effective_key,
model_name or "",
context_length=context_length,
name=display_name,
api_mode=api_mode,
)
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
@ -11248,6 +11515,24 @@ def cmd_logs(args):
since=getattr(args, "since", None),
component=getattr(args, "component", None),
)
def _build_provider_choices() -> list[str]:
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
try:
from hermes_cli.models import CANONICAL_PROVIDERS as _cp
return ["auto"] + [p.slug for p in _cp]
except Exception:
# 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",
"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",
]
# Top-level subcommands that argparse knows about WITHOUT running plugin
# discovery. Used to short-circuit eager plugin imports (which can take
# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the

View file

@ -276,6 +276,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"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",
@ -1029,6 +1038,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
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)"),
@ -1222,6 +1232,12 @@ _PROVIDER_ALIASES = {
"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",
@ -2192,6 +2208,32 @@ 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.
@ -2222,6 +2264,10 @@ 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

@ -81,6 +81,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
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",
@ -314,6 +319,13 @@ ALIASES: Dict[str, str] = {
"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",

View file

@ -27,6 +27,7 @@ from hermes_cli.auth import (
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,
@ -334,6 +335,9 @@ def _resolve_runtime_from_pool_entry(
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
@ -1634,6 +1638,26 @@ def resolve_runtime_provider(
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

@ -0,0 +1,392 @@
"""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_client_credentials_raise_with_setup_hint(self):
from agent.antigravity_oauth import AntigravityOAuthError, _require_client_id
with pytest.raises(AntigravityOAuthError) as exc_info:
_require_client_id()
assert exc_info.value.code == "antigravity_oauth_client_id_missing"
assert "HERMES_ANTIGRAVITY_CLI_PATH" in str(exc_info.value)
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

View file

@ -610,6 +610,7 @@ class TestBuildGeminiRequest:
fc_part = next(p for p in model_turn["parts"] if "functionCall" in p)
assert fc_part["functionCall"]["name"] == "get_weather"
assert fc_part["functionCall"]["args"] == {"city": "SF"}
assert fc_part["functionCall"]["id"] == "call_1"
def test_tool_result_translation(self):
from agent.gemini_cloudcode_adapter import build_gemini_request
@ -632,6 +633,7 @@ class TestBuildGeminiRequest:
fr_part = next(p for p in last["parts"] if "functionResponse" in p)
assert fr_part["functionResponse"]["name"] == "get_weather"
assert fr_part["functionResponse"]["response"] == {"temp": 72}
assert fr_part["functionResponse"]["id"] == "c1"
def test_tools_translated_to_function_declarations(self):
from agent.gemini_cloudcode_adapter import build_gemini_request
@ -790,7 +792,7 @@ class TestTranslateGeminiResponse:
"response": {
"candidates": [{
"content": {"parts": [{
"functionCall": {"name": "lookup", "args": {"q": "weather"}},
"functionCall": {"name": "lookup", "args": {"q": "weather"}, "id": "provider-call-1"},
}]},
"finishReason": "STOP",
}],
@ -798,6 +800,7 @@ class TestTranslateGeminiResponse:
}
result = _translate_gemini_response(resp, model="gemini-2.5-flash")
tc = result.choices[0].message.tool_calls[0]
assert tc.id == "provider-call-1"
assert tc.function.name == "lookup"
assert json.loads(tc.function.arguments) == {"q": "weather"}
assert result.choices[0].finish_reason == "tool_calls"

View file

@ -418,6 +418,20 @@ class TestChatCompletionsBuildKwargs:
}
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

@ -316,6 +316,41 @@ 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

@ -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 / Gemini (`gemini`, `google-gemini-cli`, `google-antigravity`)
- Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)
- DeepSeek
- Z.AI

View file

@ -111,6 +111,19 @@ hermes model
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:
@ -193,6 +206,7 @@ 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:

View file

@ -49,6 +49,7 @@ 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`) |
@ -78,6 +79,64 @@ 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.
@ -1532,7 +1591,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`, `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`, `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`.
:::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`, `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`, `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`). |
| `-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

@ -70,6 +70,10 @@ Hermes reads environment variables from the process environment and, for user-ma
| `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, OpenRouter, or 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)
- **z.ai / ZhipuAI** — GLM models
- **Kimi / Moonshot AI** — Kimi models
- **MiniMax** — global and China endpoints

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`, `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`, `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"`).
:::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

@ -63,6 +63,7 @@ Each entry requires both `provider` and `model`. Entries missing either field ar
| 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) |