diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 70f8fec736c..ca45d79af64 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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 diff --git a/agent/antigravity_cloudcode_adapter.py b/agent/antigravity_cloudcode_adapter.py new file mode 100644 index 00000000000..722afb2819f --- /dev/null +++ b/agent/antigravity_cloudcode_adapter.py @@ -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() diff --git a/agent/antigravity_code_assist.py b/agent/antigravity_code_assist.py new file mode 100644 index 00000000000..c1e9d767af4 --- /dev/null +++ b/agent/antigravity_code_assist.py @@ -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) diff --git a/agent/antigravity_oauth.py b/agent/antigravity_oauth.py new file mode 100644 index 00000000000..0422089015e --- /dev/null +++ b/agent/antigravity_oauth.py @@ -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"

{msg}

".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, + } diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index 222327807be..7473b6ebdac 100644 --- a/agent/gemini_cloudcode_adapter.py +++ b/agent/gemini_cloudcode_adapter.py @@ -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, }, diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index e7a7a0a133e..9a4794732d3 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -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 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 10d704cee80..0756a6fdad7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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": diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index f1f87c7703c..dbec732be45 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -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) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 29335e910e6..ec928d3aff6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0d848445ddc..4968f738392 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f84ac69564e..a507b830387 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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()) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index efc3a8576ed..15c5cb0b508 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 68919eaac62..da0eee11dca 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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 { diff --git a/tests/agent/test_antigravity_cloudcode.py b/tests/agent/test_antigravity_cloudcode.py new file mode 100644 index 00000000000..71aabb972a1 --- /dev/null +++ b/tests/agent/test_antigravity_cloudcode.py @@ -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 diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py index 600a06ffe93..1c72088221d 100644 --- a/tests/agent/test_gemini_cloudcode.py +++ b/tests/agent/test_gemini_cloudcode.py @@ -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" diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index addfa479688..665df0c3221 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -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. diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 75eb5b8dc70..a791eac0af1 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -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: diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index b412ff479a3..c7aee421ca5 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -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 diff --git a/website/docs/guides/google-gemini.md b/website/docs/guides/google-gemini.md index 0994bb26102..bf090025ac1 100644 --- a/website/docs/guides/google-gemini.md +++ b/website/docs/guides/google-gemini.md @@ -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: diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 46d7958cc42..e51b46cb69e 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index fea7f81499b..2f64f04c59f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -100,7 +100,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--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 ` | 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 ` | 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. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index fa20735f217..41a099eb7ac 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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 | diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 75e49b2a292..c95a62859a0 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -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 diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 939bf36efff..8c97de1b17a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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). diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index dbe431fc1ea..28a5d0e1fce 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -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) |