Gemini CLI sunset, Antigravity OAuth provider transition

NousResearch/hermes-agent · cluster 982 · agent

Data as of

Nothing open on GitHub. Fix merged in #50454. Check it covers your case.

The short answer

0open issues
0open PRs
16items grouped
Known?
Yes. 16 related items grouped here, 11 issues already closed.
Fix event
A fix was merged to main in PR #50454. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #50454. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern the deprecation and removal of the google-gemini-cli provider following the June 2025 Code Assist sunset, and ongoing efforts to add or replace it with an Antigravity/native OAuth provider for Gemini access. Multiple competing approaches and unmerged PRs reflect an unresolved transition.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50454 fix(antigravity): native google-antigravity OAuth provider (Gemini CLI sunset)

    merged to main

    files: agent/agent_runtime_helpers.py, agent/antigravity_cloudcode_adapter.py, agent/antigravity_code_assist.py, agent/antigravity_oauth.py, agent/gemini_cloudcode_adapter.py, agent/transports/chat_completions.py, hermes_cli/auth.py, hermes_cli/auth_commands.py, hermes_cli/config.py, hermes_cli/main.py, hermes_cli/model_setup_flows.py, hermes_cli/models.py, hermes_cli/providers.py, hermes_cli/runtime_provider.py, plugins/model-providers/gemini/__init__.py, scripts/release.py, tests/agent/test_antigravity_cloudcode.py, tests/agent/test_gemini_cloudcode.py, tests/agent/transports/test_chat_completions.py, tests/hermes_cli/test_model_provider_persistence.py (+8 more)

    Show the diff
    • 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
       
    • 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: [redacted:secret-assignment] = None,
      +        base_url: Optional[str] = None,
      +        default_headers: Optional[Dict[str, str]] = None,
      +        project_id: str = "",
      +        **kwargs: Any,
      +    ):
      +        super().__init__(
      +            api_key=[redacted:secret-assignment] 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: [redacted:secret-assignment] = None,
      +        top_p: Optional[float] = None,
      +        stop: Any = None,
      +        extra_body: Optional[Dict[str, Any]] = None,
      +        timeout: Any = None,
      +        **_: Any,
      +    ) -> Any:
      +        access_token = [redacted:secret-assignment]
      +        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=[redacted:secret-assignment]
      +            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()
    • agent/antigravity_code_assist.py
      @@ -0,0 +1,286 @@
      +"""Antigravity Code Assist control-plane helpers.
      +
      +The new Antigravity CLI uses the same v1internal Code Assist family as
      +gemini-cli, but with Antigravity OAuth scopes, metadata and model catalog. This
      +module keeps that provider-specific surface separate from
      +``agent.google_code_assist``.
      +"""
      +
      +from __future__ import annotations
      +
      +import json
      +import logging
      +import urllib.error
      +import urllib.request
      +import uuid
      +from dataclasses import dataclass, field
      +from typing import Any, Dict, Iterable, List, Optional
      +
      +from agent.google_code_assist import CodeAssistError
      +
      +logger = logging.getLogger(__name__)
      +
      +ANTIGRAVITY_CODE_ASSIST_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
      +ANTIGRAVITY_MODEL_ENDPOINTS = [
      +    ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
      +    "https://cloudcode-pa.googleapis.com",
      +    "https://autopush-cloudcode-pa.sandbox.googleapis.com",
      +]
      +
      +ANTIGRAVITY_CLIENT_METADATA = {
      +    "ideType": "ANTIGRAVITY",
      +    "platform": "PLATFORM_UNSPECIFIED",
      +    "pluginType": "GEMINI",
      +}
      +ANTIGRAVITY_USER_AGENT = "antigravity/1.0.0 windows/amd64"
      +ANTIGRAVITY_X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"
      +
      +DEFAULT_AGENT_MODEL_IDS = [
      +    "gemini-3-flash-agent",
      +    "gemini-3.5-flash-low",
      +    "gemini-pro-agent",
      +    "gemini-3.1-pro-low",
      +    "claude-sonnet-4-6",
      +    "claude-opus-4-6-thinking",
      +    "gpt-oss-120b-medium",
      +]
      +
      +DEPRECATED_MODEL_REPLACEMENTS = {
      +    "gemini-3.1-pro-high": "gemini-pro-agent",
      +}
      +
      +
      +@dataclass
      +class AntigravityProjectInfo:
      +    project_id: str = ""
      +    raw: Dict[str, Any] = field(default_factory=dict)
      +
      +
      +@dataclass
      +class ProjectContext:
      +    project_id: str = ""
      +    managed_project_id: str = ""
      +    tier_id: str = ""
      +    source: str = ""
      +
      +
      +def _client_metadata() -> Dict[str, str]:
      +    return dict(ANTIGRAVITY_CLIENT_METADATA)
      +
      +
      +def build_headers(access_token: str, *, accept: str = "application/json") -> Dict[str, str]:
      +    return {
      +        "Content-Type": "application/json",
      +        "Accept": accept,
      +        "Authorization": f"Bearer {access_token}",
      +        "User-Agent": ANTIGRAVITY_USER_AGENT,
      +        "X-Goog-Api-Client": ANTIGRAVITY_X_GOOG_API_CLIENT,
      +        "Client-Metadata": json.dumps(_client_metadata(), separators=(",", ":")),
      +        "x-activity-request-id": str(uuid.uuid4()),
      +    }
      +
      +
      +def _post_json(
      +    url: str,
      +    body: Dict[str, Any],
      +    access_token: str,
      +    *,
      +    timeout: float = 30.0,
      +) -> Dict[str, Any]:
      +    data = json.dumps(body).encode("utf-8")
      +    request = urllib.request.Request(
      +        url,
      +        data=data,
      +        method="POST",
      +        headers=build_headers(access_token),
      +    )
      +    try:
      +        with urllib.request.urlopen(request, timeout=timeout) as response:
      +            raw = response.read().decode("utf-8", errors="replace")
      +            return json.loads(raw) if raw else {}
      +    except urllib.error.HTTPError as exc:
      +        detail = ""
      +        try:
      +            detail = exc.read().decode("utf-8", errors="replace")
      +        except Exception:
      +            pass
      +        raise CodeAssistError(
      +            f"Antigravity Code Assist HTTP {exc.code}: {detail or exc.reason}",
      +            code=f"antigravity_code_assist_http_{exc.code}",
      +        ) from exc
      +    except urllib.error.URLError as exc:
      +        raise CodeAssistError(
      +            f"Antigravity Code Assist request failed: {exc}",
      +            code="antigravity_code_assist_network_error",
      +        ) from exc
      +
      +
      +def load_code_assist(
      +    access_token: str,
      +    *,
      +    project_id: str = "",
      +    endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
      +) -> AntigravityProjectInfo:
      +    metadata = _client_metadata()
      +    if project_id:
      +        metadata["duetProject"] = project_id
      +    body: Dict[str, Any] = {"metadata": metadata}
      +    if project_id:
      +        body["cloudaicompanionProject"] = project_id
      +    resp = _post_json(f"{endpoint}/v1internal:loadCodeAssist", body, access_token)
      +    project = (
      +        str(resp.get("cloudaicompanionProject") or "").strip()
      +        or str(resp.get("project") or "").strip()
      +    )
      +    return AntigravityProjectInfo(project_id=project, raw=resp)
      +
      +
      +def resolve_project_context(
      +    access_token: str,
      +    *,
      +    configured_project_id: str = "",
      +    env_project_id: str = "",
      +) -> ProjectContext:
      +    if configured_project_id:
      +        return ProjectContext(project_id=configured_project_id, source="config")
      +    if env_project_id:
      +        return ProjectContext(project_id=env_project_id, source="env")
      +    info = load_code_assist(access_token)
      +    if info.project_id:
      +        return ProjectContext(
      +            project_id=info.project_id,
      +            managed_project_id=info.project_id,
      +            source="discovered",
      +        )
      +    # Discovery returned no project (common on fresh consumer accounts that
      +    # haven't been onboarded). Fall back to the public default project so the
      +    # call chain still succeeds — mirrors the Antigravity CLI reference flow.
      +    from agent.antigravity_oauth import DEFAULT_PROJECT_ID
      +    return ProjectContext(
      +        project_id=DEFAULT_PROJECT_ID,
      +        managed_project_id=DEFAULT_PROJECT_ID,
      +        source="default",
      +    )
      +
      +
      +def fetch_available_models(
      +    access_token: str,
      +    *,
      +    project_id: str = "",
      +    endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
      +) -> Dict[str, Any]:
      +    body: Dict[str, Any] = {}
      +    if project_id:
      +        body["project"] = project_id
      +    return _post_json(f"{endpoint}/v1internal:fetchAvailableModels", body, access_token)
      +
      +
      +def fetch_available_models_with_fallbacks(
      +    access_token: str,
      +    *,
      +    project_id: str = "",
      +    endpoints: Optional[Iterable[str]] = None,
      +) -> Dict[str, Any]:
      +    last_err: Optional[Exception] = None
      +    for endpoint in endpoints or ANTIGRAVITY_MODEL_ENDPOINTS:
      +        try:
      +            return fetch_available_models(
      +                access_token,
      +                project_id=project_id,
      +                endpoint=endpoint,
      +            )
      +        except Exception as exc:
      +            last_err = exc
      +            logger.debug("Antigravity fetchAvailableModels failed on %s: %s", endpoint, exc)
      +    if last_err:
      +        raise last_err
      +    return {}
      +
      +
      +def _model_id_from_value(value: Any) -> str:
      +    if isinstance(value, str):
      +        return value.strip()
      +    if isinstance(value, dict):
      +        for key in ("modelId", "model_id", "id", "name"):
      +            candidate = str(value.get(key) or "").strip()
      +            if candidate:
      +                return candidate
      +    return ""
      +
      +
      +def _ids_from_sort(sort: Dict[str, Any]) -> List[str]:
      +    ids: List[str] = []
      +    for key in ("modelIds", "model_ids", "models", "modelSorts"):
      +        value = sort.get(key)
      +        if isinstance(value, list):
      +            for item in value:
      +                mid = _model_id_from_value(item)
      +                if mid:
      +                    ids.append(mid)
      +        elif isinstance(value, dict):
      +            mid = _model_id_from_value(value)
      +            if mid:
      +                ids.append(mid)
      +    return ids
      +
      +
      +def _is_recommended_sort(sort: Dict[str, Any]) -> bool:
      +    label = " ".join(
      +        str(sort.get(key) or "")
      +        for key in ("name", "displayName", "title", "category", "group")
      +    ).lower()
      +    return "recommended" in label
      +
      +
      +def _raw_model_ids(payload: Dict[str, Any]) -> List[str]:
      +    ids: List[str] = []
      +    models = payload.get("models")
      +    if isinstance(models, list):
      +        for item in models:
      +            mid = _model_id_from_value(item)
      +            if mid:
      +                ids.append(mid)
      +    return ids
      +
      +
      +def filter_agent_model_ids(ids: Iterable[str]) -> List[str]:
      +    seen: set[str] = set()
      +    filtered: List[str] = []
      +    raw = [str(mid).strip() for mid in ids if str(mid).strip()]
      +    replacements = set(DEPRECATED_MODEL_REPLACEMENTS.values())
      +    for mid in raw:
      +        if mid in seen:
      +            continue
      +        if mid.startswith(("chat_", "tab_")):
      +            continue
      +        if mid in DEPRECATED_MODEL_REPLACEMENTS and DEPRECATED_MODEL_REPLACEMENTS[mid] in raw:
      +            continue
      +        if mid in replacements and mid in seen:
      +            continue
      +        seen.add(mid)
      +        filtered.append(mid)
      +    return filtered
      +
      +
      +def parse_agent_model_ids(payload: Dict[str, Any]) -> List[str]:
      +    """Return the user-facing Antigravity agent model list in display order."""
      +    sorts = payload.get("agentModelSorts")
      +    ordered: List[str] = []
      +    if isinstance(sorts, list):
      +        recommended = [s for s in sorts if isinstance(s, dict) and _is_recommended_sort(s)]
      +        rest = [s for s in sorts if isinstance(s, dict) and not _is_recommended_sort(s)]
      +        for sort in recommended + rest:
      +            ordered.extend(_ids_from_sort(sort))
      +
      +    if not ordered:
      +        default_id = str(payload.get("defaultAgentModelId") or "").strip()
      +        if default_id:
      +            ordered.append(default_id)
      +        for mid in DEFAULT_AGENT_MODEL_IDS:
      +            ordered.append(mid)
      +        ordered.extend(_raw_model_ids(payload))
      +
      +    filtered = filter_agent_model_ids(ordered)
      +    if filtered:
      +        return filtered
      +    return list(DEFAULT_AGENT_MODEL_IDS)
    • agent/antigravity_oauth.py
      @@ -0,0 +1,907 @@
      +"""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 = "[redacted:secret-assignment]"
      +ENV_CLI_PATH = "HERMES_ANTIGRAVITY_CLI_PATH"
      +
      +# Public Antigravity CLI desktop OAuth client. Like Google's gemini-cli
      +# credentials (see agent/google_oauth.py), this is a DESKTOP OAuth client and
      +# its "secret" is not confidential — installed-app clients have no
      +# secret-keeping requirement (PKCE provides the security), and these creds are
      +# baked into every copy of the Antigravity CLI. Shipping them as a fallback
      +# lets users without `agy` installed authenticate directly. Split into parts
      +# with explicit comments per the convention in google_oauth.py.
      +_PUBLIC_CLIENT_ID_PROJECT_NUM = "1071006060591"
      +_PUBLIC_CLIENT_ID_HASH = "tmhssin2h21lcre235vtolojh4g403ep"
      +_PUBLIC_CLIENT_SECRET_SUFFIX = "[redacted:secret-assignment]"
      +
      +_DEFAULT_CLIENT_ID = (
      +    f"{_PUBLIC_CLIENT_ID_PROJECT_NUM}-{_PUBLIC_CLIENT_ID_HASH}"
      +    ".apps.googleusercontent.com"
      +)
      +_DEFAULT_CLIENT_SECRET = f"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}"
      +
      +# Fallback project ID when Code Assist project discovery fails entirely.
      +DEFAULT_PROJECT_ID = "rising-fact-p41fc"
      +
      +_CLIENT_ID_PATTERN = re.compile(
      +    r"([0-9]{8,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com)"
      +)
      +_CLIENT_SECRET_PATTERN = [redacted:secret-assignment]"(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 = "[redacted:secret-assignment]"
      +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()
      +
      +
      [redacted:email]
      +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: [redacted:secret-assignment] = []
      +    for match in _CLIENT_SECRET_PATTERN.finditer(content):
      +        secrets.extend(_secret_candidates(match.group(1)))
      +    secrets = [redacted:secret-assignment]
      +    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 = [redacted:secret-assignment]
      +            _discovered_creds_cache.update({
      +                "client_id": client_id,
      +                "client_secret": client_secret,
      +                "candidates": candidates,
      +                "resolved": "1",
      +            })
      +            logger.info("Discovered Antigravity OAuth client credentials from %s", path)
      +            return client_id, client_secret
      +
      +    _discovered_creds_cache["resolved"] = "1"
      +    return "", ""
      +
      +
      +def _get_client_id() -> str:
      +    env_val = (os.getenv(ENV_CLIENT_ID) or "").strip()
      +    if env_val:
      +        return env_val
      +    discovered, _ = _discover_client_credentials()
      +    if discovered:
      +        return discovered
      +    return _DEFAULT_CLIENT_ID
      +
      +
      +def _get_client_secret() -> str:
      +    env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
      +    if env_val:
      +        return env_val
      +    _, discovered = _discover_client_credentials()
      +    if discovered:
      +        return discovered
      +    return _DEFAULT_CLIENT_SECRET
      +
      +
      +def _iter_client_credential_candidates() -> list[Tuple[str, str]]:
      +    env_id = (os.getenv(ENV_CLIENT_ID) or "").strip()
      +    env_secret = [redacted:secret-assignment] or "").strip()
      +    if env_id and env_secret:
      +        return [(env_id, env_secret)]
      +
      +    _discover_client_credentials()
      +    cached = _discovered_creds_cache.get("candidates")
      +    candidates: list[Tuple[str, str]] = []
      +    if isinstance(cached, list):
      +        candidates = [
      +            (str(client_id), str(client_secret))
      +            for client_id, client_secret in cached
      +            if client_id and client_secret
      +        ]
      +    else:
      +        client_id = str(_discovered_creds_cache.get("client_id") or "")
      +        client_secret = [redacted:secret-assignment]"client_secret") or "")
      +        if client_id and client_secret:
      +            candidates = [(client_id, client_secret)]
      +
      +    # Always include the public baked-in default as a last-resort candidate so
      +    # users without `agy` installed can still authenticate. De-dupe in case
      +    # discovery already surfaced the same client.
      +    default_pair = (_DEFAULT_CLIENT_ID, _DEFAULT_CLIENT_SECRET)
      +    if default_pair not in candidates:
      +        candidates.append(default_pair)
      +    return candidates
      +
      +
      +def _require_client_id() -> str:
      +    client_id = _get_client_id()
      +    if not client_id:
      +        raise AntigravityOAuthError(
      +            "Antigravity OAuth client ID is not available. Install Antigravity CLI "
      +            "so Hermes can discover its desktop OAuth client, set "
      +            f"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and "
      +            f"{ENV_CLIENT_SECRET} in ~/.hermes/.env.",
      +            code="antigravity_oauth_client_id_missing",
      +        )
      +    return client_id
      +
      +
      +def _require_client_secret() -> str:
      +    client_secret = [redacted:secret-assignment]
      +    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=[redacted:secret-assignment]
      +            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=[redacted:secret-assignment]
      +                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=[redacted:secret-assignment]"access", "") or ""),
      +            refresh_token=[redacted:secret-assignment]
      +            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 = [redacted:secret-assignment]
      +        creds.refresh_token = [redacted:secret-assignment]"refresh_token", "") or "").strip() or creds.refresh_token
      +        expires_in = int(resp.get("expires_in", 0) or 0)
      +        creds.expires_ms = int((time.time() + max(60, expires_in)) * 1000)
      +        save_credentials(creds)
      +        return creds.access_token
      +    finally:
      +        if owner:
      +            with _refresh_inflight_lock:
      +                _refresh_inflight.pop(rt, None)
      +            event.set()
      +
      +
      +def update_project_ids(project_id: str = "", managed_project_id: str = "") -> None:
      +    creds = load_credentials()
      +    if creds is None:
      +        return
      +    if project_id:
      +        creds.project_id = project_id
      +    if managed_project_id:
      +        creds.managed_project_id = managed_project_id
      +    save_credentials(creds)
      +
      +
      +class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
      +    expected_state: str = ""
      +    captured_code: Optional[str] = None
      +    captured_error: Optional[str] = None
      +    ready: Optional[threading.Event] = None
      +
      +    def log_message(self, format: str, *args: Any) -> None:  # noqa: A002, N802
      +        logger.debug("Antigravity OAuth callback: " + format, *args)
      +
      +    def do_GET(self) -> None:  # noqa: N802
      +        parsed = urllib.parse.urlparse(self.path)
      +        if parsed.path != CALLBACK_PATH:
      +            self.send_response(404)
      +            self.end_headers()
      +            return
      +
      +        params = urllib.parse.parse_qs(parsed.query)
      +        state = (params.get("state") or [""])[0]
      +        error = (params.get("error") or [""])[0]
      +        code = (params.get("code") or [""])[0]
      +
      +        handler_cls = type(self)
      +        if state != self.expected_state:
      +            handler_cls.captured_error = "OAuth state mismatch."
      +        elif error:
      +            handler_cls.captured_error = error
      +        elif not code:
      +            handler_cls.captured_error = "OAuth callback did not include a code."
      +        else:
      +            handler_cls.captured_code = code
      +
      +        ok = not handler_cls.captured_error
      +        self.send_response(200 if ok else 400)
      +        self.send_header("Content-Type", "text/html; charset=utf-8")
      +        self.end_headers()
      +        msg = "Antigravity OAuth complete. You can return to Hermes." if ok else handler_cls.captured_error
      +        self.wfile.write(f"<html><body><p>{msg}</p></body></html>".encode("utf-8"))
      +        if handler_cls.ready is not None:
      +            handler_cls.ready.set()
      +
      +
      +class _ReusableHTTPServer(http.server.HTTPServer):
      +    allow_reuse_address = True
      +
      +
      +def resolve_project_id_from_env() -> str:
      +    for key in ("HERMES_ANTIGRAVITY_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT_ID"):
      +        value = (os.getenv(key) or "").strip()
      +        if value:
      +            return value
      +    return ""
      +
      +
      +def start_oauth_flow(
      +    *,
      +    force_relogin: bool = False,
      +    open_browser: bool = True,
      +    port: int = DEFAULT_REDIRECT_PORT,
      +    project_id: str = "",
      +) -> AntigravityCredentials:
      +    if not force_relogin:
      +        existing = load_credentials()
      +        if existing and not existing.access_token_expired():
      +            return existing
      +
      +    verifier, challenge = _generate_pkce_pair()
      +    state = secrets.token_urlsafe(24)
      +    client_id, _ = _require_client_credentials()
      +
      +    ready = threading.Event()
      +    handler_cls = type("AntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {})
      +    handler_cls.expected_state = state
      +    handler_cls.captured_code = None
      +    handler_cls.captured_error = None
      +    handler_cls.ready = ready
      +
      +    try:
      +        server = _ReusableHTTPServer((REDIRECT_HOST, int(port)), handler_cls)
      +    except OSError:
      +        server = _ReusableHTTPServer((REDIRECT_HOST, 0), handler_cls)
      +    actual_port = int(server.server_address[1])
      +    redirect_uri = f"http://{REDIRECT_HOST}:{actual_port}{CALLBACK_PATH}"
      +
      +    thread = threading.Thread(target=server.serve_forever, daemon=True)
      +    thread.start()
      +    try:
      +        params = {
      +            "client_id": client_id,
      +            "redirect_uri": redirect_uri,
      +            "response_type": "code",
      +            "scope": OAUTH_SCOPES,
      +            "access_type": "offline",
      +            "prompt": "consent",
      +            "state": state,
      +            "code_challenge": challenge,
      +            "code_challenge_method": "S256",
      +        }
      +        auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
      +        print("Open this URL to authorize Antigravity OAuth:")
      +        print(auth_url)
      +        if open_browser:
      +            webbrowser.open(auth_url)
      +        if not ready.wait(timeout=CALLBACK_WAIT_SECONDS):
      +            raise AntigravityOAuthError(
      +                "Timed out waiting for Antigravity OAuth callback.",
      +                code="antigravity_oauth_callback_timeout",
      +            )
      +        if handler_cls.captured_error:
      +            raise AntigravityOAuthError(
      +                handler_cls.captured_error,
      +                code="antigravity_oauth_callback_error",
      +            )
      +        code = handler_cls.captured_code or ""
      +        token = [redacted:secret-assignment] verifier, redirect_uri)
      +    finally:
      +        server.shutdown()
      +        server.server_close()
      +
      +    access_token = [redacted:secret-assignment]"access_token", "") or "").strip()
      +    refresh_token = [redacted:secret-assignment]"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=[redacted:secret-assignment]
      +        refresh_token=[redacted:secret-assignment]
      +        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,
      +    }
    • 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,
                       },
    • agent/transports/chat_completions.py
      @@ -437,7 +437,7 @@ def build_kwargs(
                           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
    • hermes_cli/auth.py
      @@ -142,6 +142,9 @@
       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 @@ class 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 = [redacted:secret-assignment]
      +    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":
    • hermes_cli/auth_commands.py
      @@ -34,7 +34,7 @@
       
       
       # 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=[redacted:secret-assignment]"access_token"],
      +            refresh_token=[redacted:secret-assignment]"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)
    • hermes_cli/config.py
      @@ -3100,6 +3100,38 @@ def _ensure_hermes_home_managed(home: Path):
               "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",
    • hermes_cli/main.py
      @@ -603,6 +603,7 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
           _model_flow_qwen_oauth,
           _model_flow_minimax_oauth,
           _model_flow_google_gemini_cli,
      +    _model_flow_google_antigravity,
           _model_flow_custom,
           _model_flow_azure_foundry,
           _model_flow_named_custom,
      @@ -3074,6 +3075,8 @@ def _active_custom_key_from_base_url() -> str:
               _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":
      @@ -3603,14 +3606,6 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
       ]
       
       
      -
      -
      -
      -
      -
      -
      -
      -
       def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
           """Prompt for a custom provider API mode.
       
      @@ -11248,6 +11243,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
    • hermes_cli/model_setup_flows.py
      @@ -712,6 +712,64 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
           else:
               print("No change.")
       
      +
      +def _model_flow_google_antigravity(_config, current_model=""):
      +    """Google Antigravity OAuth via Antigravity Code Assist.
      +
      +    Antigravity is Google's consumer successor to the Gemini CLI. It reuses the
      +    Code Assist backend with a distinct OAuth client + scopes. Leaves the
      +    `google-gemini-cli` provider (Enterprise Code Assist) untouched.
      +    """
      +    from hermes_cli.auth import (
      +        DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
      +        get_antigravity_oauth_auth_status,
      +        resolve_antigravity_oauth_runtime_credentials,
      +        _prompt_model_selection,
      +        _save_model_choice,
      +        _update_config_for_provider,
      +    )
      +    from hermes_cli.models import provider_model_ids
      +
      +    status = get_antigravity_oauth_auth_status()
      +    if not status.get("logged_in"):
      +        try:
      +            from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow
      +
      +            env_project = resolve_project_id_from_env()
      +            start_oauth_flow(force_relogin=True, project_id=env_project)
      +        except Exception as exc:
      +            print(f"OAuth login failed: {exc}")
      +            return
      +
      +    try:
      +        creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False)
      +        project_id = creds.get("project_id", "")
      +        if project_id:
      +            print(f"  Using Antigravity project: {project_id}")
      +    except Exception as exc:
      +        print(f"Failed to resolve Antigravity credentials: {exc}")
      +        return
      +
      +    models = provider_model_ids("google-antigravity")
      +    default = current_model or (models[0] if models else "gemini-3-flash-agent")
      +    selected = _prompt_model_selection(
      +        models,
      +        current_model=default,
      +        confirm_provider="google-antigravity",
      +        confirm_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
      +    )
      +    if selected:
      +        _save_model_choice(selected)
      +        _update_config_for_provider(
      +            "google-antigravity", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL
      +        )
      +        print(
      +            f"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)"
      +        )
      +    else:
      +        print("No change.")
      +
      +
       def _model_flow_custom(config):
           """Custom endpoint: collect URL, API key, and model name.
       
    • hermes_cli/models.py
      @@ -276,6 +276,15 @@ def _xai_curated_models() -> 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 @@ class ProviderEntry(NamedTuple):
           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 @@ def group_providers(slugs):
           "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",
      @@ -1788,6 +1804,15 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
           {"nous", "openrouter", "copilot", "kilocode"}
       )
       
      +# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
      +# (e.g. google-antigravity serves Claude / Gemini / GPT-OSS where the account
      +# is entitled). For bare short-alias resolution (`sonnet`, `opus`, ...) these
      +# must NOT hijack the alias away from the model's native vendor provider
      +# (`anthropic`, `gemini`, ...). They're tried only as a last resort, after
      +# every native-vendor catalog. They are NOT aggregators (an explicit switch TO
      +# them is still valid), so they stay out of _AGGREGATOR_PROVIDERS.
      +_BORROWED_MODEL_PROVIDERS = frozenset({"google-antigravity"})
      +
       
       def _resolve_static_model_alias(
           name_lower: str,
      @@ -1825,7 +1850,11 @@ def _match(provider: str) -> Optional[str]:
                   return provider, matched
       
           for provider in _PROVIDER_MODELS:
      -        if provider in current_keys or provider in _AGGREGATOR_PROVIDERS:
      +        if (
      +            provider in current_keys
      +            or provider in _AGGREGATOR_PROVIDERS
      +            or provider in _BORROWED_MODEL_PROVIDERS
      +        ):
                   continue
               if matched := _match(provider):
                   return provider, matched
      @@ -1834,6 +1863,13 @@ def _match(provider: str) -> Optional[str]:
               if provider in current_keys and (matched := _match(provider)):
                   return provider, matched
       
      +    # Last resort: providers that re-expose other vendors' models (e.g.
      +    # google-antigravity serving Claude). Only reached when no native-vendor
      +    # catalog matched — so `sonnet` resolves to anthropic, not antigravity.
      +    for provider in _BORROWED_MODEL_PROVIDERS:
      +        if provider in current_keys and (matched := _match(provider)):
      +            return provider, matched
      +
           return None
       
       
      @@ -1880,11 +1916,23 @@ def detect_static_provider_for_model(
       
           # --- Step 1: check static provider catalogs for a direct match ---
           for pid, models in _PROVIDER_MODELS.items():
      -        if pid in current_keys or pid in _AGGREGATOR_PROVIDERS:
      +        if (
      +            pid in current_keys
      +            or pid in _AGGREGATOR_PROVIDERS
      +            or pid in _BORROWED_MODEL_PROVIDERS
      +        ):
                   continue
               if any(name_lower == m.lower() for m in models):
                   return (pid, name)
       
      +    # Borrow-list providers (re-expose other vendors' models) only after every
      +    # native-vendor catalog, and only when one is the current provider.
      +    for pid in _BORROWED_MODEL_PROVIDERS:
      +        if pid in current_keys:
      +            continue
      +        if any(name_lower == m.lower() for m in _PROVIDER_MODELS.get(pid, [])):
      +            return (pid, name)
      +
           return None
       
       
      @@ -2192,6 +2240,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 = [redacted:secret-assignment]"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 +2296,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
               return get_codex_model_ids(access_token=[redacted:secret-assignment]
           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())
    • hermes_cli/providers.py
      @@ -81,6 +81,11 @@ class 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 @@ class ProviderDef:
           "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",
    • hermes_cli/runtime_provider.py
      @@ -27,6 +27,7 @@
           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 {
    • plugins/model-providers/gemini/__init__.py
      @@ -2,6 +2,7 @@
       
       gemini:            Google AI Studio (API key) — uses GeminiNativeClient
       google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
      +google-antigravity: Google Antigravity Code Assist (OAuth) — uses AntigravityCloudCodeClient
       
       Both report api_mode="chat_completions" but use custom native clients
       that bypass the standard OpenAI transport. The profile captures auth
      @@ -68,5 +69,22 @@ def build_extra_body(
           auth_type="oauth_external",
       )
       
      +google_antigravity = GeminiProfile(
      +    name="google-antigravity",
      +    aliases=(
      +        "antigravity",
      +        "antigravity-oauth",
      +        "antigravity-cli",
      +        "google-antigravity-oauth",
      +        "agy",
      +        "agy-cli",
      +    ),
      +    api_mode="chat_completions",
      +    env_vars=(),  # OAuth — no API key
      +    base_url="antigravity-pa://google",  # Antigravity Code Assist internal scheme
      +    auth_type="oauth_external",
      +)
      +
       register_provider(gemini)
       register_provider(google_gemini_cli)
      +register_provider(google_antigravity)
    • scripts/release.py
      @@ -45,6 +45,7 @@
       
       # Auto-extracted from noreply emails + manual overrides
       AUTHOR_MAP = {
      +    "[redacted:email]": "pmos69",  # PR #29474 salvage (native Antigravity OAuth provider; Gemini CLI sunset #29294/#49701)
           "[redacted:email]": "orbisai0security",  # PR #9560 salvage (session.py path-traversal guard, V-009)
           "[redacted:email]": "panghuer023",  # PR #37994 salvage (interrupt unblocks pending gateway approval; #8697)
           "[redacted:email]": "natehale",  # PR #48678 salvage (typing indicator lingers after final reply)
    • tests/agent/test_antigravity_cloudcode.py
      @@ -0,0 +1,405 @@
      +"""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
      +
      +
      [redacted:email](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="[redacted:email]",
      +            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="[redacted:secret-assignment]";\n',
      +            encoding="utf-8",
      +        )
      +        monkeypatch.setenv("HERMES_ANTIGRAVITY_CLI_PATH", str(fake_agy))
      +        antigravity_oauth._discovered_creds_cache.clear()
      +
      +        assert antigravity_oauth._get_client_id().startswith("1071006060591-")
      +        assert antigravity_oauth._get_client_secret() == fake_client_secret
      +
      +    def test_missing_discovery_falls_back_to_public_default(self, monkeypatch):
      +        # With no env override and no discoverable agy install, the public
      +        # baked-in Antigravity desktop OAuth client is used as the floor so
      +        # users without `agy` installed can still authenticate (PKCE makes the
      +        # installed-app "secret" non-confidential, same as gemini-cli).
      +        from agent import antigravity_oauth
      +        from agent.antigravity_oauth import (
      +            _DEFAULT_CLIENT_ID,
      +            _DEFAULT_CLIENT_SECRET,
      +            _require_client_id,
      +        )
      +
      +        monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_ID", raising=False)
      +        monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", raising=False)
      +        monkeypatch.delenv("HERMES_ANTIGRAVITY_CLI_PATH", raising=False)
      +        antigravity_oauth._discovered_creds_cache.clear()
      +
      +        assert _require_client_id() == _DEFAULT_CLIENT_ID
      +        assert antigravity_oauth._get_client_secret() == _DEFAULT_CLIENT_SECRET
      +        assert _DEFAULT_CLIENT_ID.startswith("1071006060591-")
      +
      +    def test_pkce_challenge_is_s256(self):
      +        import base64
      +        import hashlib
      +
      +        from agent.antigravity_oauth import _generate_pkce_pair
      +
      +        verifier, challenge = _generate_pkce_pair()
      +        expected = base64.urlsafe_b64encode(
      +            hashlib.sha256(verifier.encode("ascii")).digest()
      +        ).rstrip(b"=").decode("ascii")
      +        assert challenge == expected
      +        assert 43 <= len(verifier) <= 128
      +
      +    def test_exchange_code_posts_pkce_payload(self, monkeypatch):
      +        from agent import antigravity_oauth
      +
      +        captured = {}
      +
      +        def fake_post(url, data, timeout):
      +            captured.update({"url": url, "data": data, "timeout": timeout})
      +            return {"access_token": "at"}
      +
      +        monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
      +        monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "client.apps.googleusercontent.com")
      +        monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "secret")
      +
      +        assert antigravity_oauth.exchange_code("code", "verifier", "http://localhost/cb") == {
      +            "access_token": "at"
      +        }
      +        assert captured["url"] == antigravity_oauth.TOKEN_ENDPOINT
      +        assert captured["data"]["grant_type"] == "authorization_code"
      +        assert captured["data"]["code_verifier"] == "verifier"
      +        assert captured["data"]["redirect_uri"] == "http://localhost/cb"
      +        assert captured["data"]["client_id"] == "client.apps.googleusercontent.com"
      +        assert captured["data"]["client_secret"] == "secret"
      +
      +    def test_refresh_tries_discovered_client_secret_candidates(self, monkeypatch):
      +        from agent import antigravity_oauth
      +        from agent.antigravity_oauth import AntigravityOAuthError
      +
      +        calls = []
      +        monkeypatch.setattr(
      +            antigravity_oauth,
      +            "_iter_client_credential_candidates",
      +            lambda: [
      +                ("client.apps.googleusercontent.com", "wrong-secret"),
      +                ("client.apps.googleusercontent.com", "right-secret"),
      +            ],
      +        )
      +
      +        def fake_post(url, data, timeout):
      +            calls.append(data["client_secret"])
      +            if data["client_secret"] == "wrong-secret":
      +                raise AntigravityOAuthError(
      +                    "invalid client",
      +                    code="antigravity_oauth_invalid_client",
      +                )
      +            return {"access_token": "new-token", "expires_in": 3600}
      +
      +        monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
      +
      +        assert antigravity_oauth.refresh_access_token("refresh-token")["access_token"] == "new-token"
      +        assert calls == ["wrong-secret", "right-secret"]
      +
      +    def test_invalid_grant_refresh_clears_credentials(self, monkeypatch):
      +        from agent import antigravity_oauth
      +        from agent.antigravity_oauth import (
      +            AntigravityCredentials,
      +            AntigravityOAuthError,
      +            load_credentials,
      +            save_credentials,
      +        )
      +
      +        save_credentials(AntigravityCredentials(
      +            access_token="[redacted:secret-assignment]",
      +            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="[redacted:secret-assignment]",
      +            refresh_token="rt",
      +            expires_ms=int((time.time() + 3600) * 1000),
      +            project_id="my-proj",
      +            email="[redacted:email]",
      +        ))
      +
      +        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"] == "[redacted:email]"
      +
      +    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
    • tests/agent/test_gemini_cloudcode.py
      @@ -610,6 +610,7 @@ def test_tool_call_translation(self):
               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 @@ def test_tool_result_translation(self):
               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 @@ def test_function_call_response(self):
                   "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 @@ def test_function_call_response(self):
               }
               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"
    • tests/agent/transports/test_chat_completions.py
      @@ -418,6 +418,20 @@ def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport):
               }
               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.
    • tests/hermes_cli/test_model_provider_persistence.py
      @@ -316,6 +316,41 @@ def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home,
               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": "[redacted:email]"},
      +        ), 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:
    • 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
    • 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:
    • 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).
    • website/docs/reference/cli-commands.md
      @@ -100,7 +100,7 @@ Common options:
       | `-q`, `--query "..."` | One-shot, non-interactive prompt. |
       | `-m`, `--model <model>` | Override the model for this run. |
       | `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
      -| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
      +| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity` (aliases: `antigravity`, `antigravity-oauth`, `agy`), `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
       | `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). |
       | `-v`, `--verbose` | Verbose output. |
       | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
    • 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 |
    • 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
    • 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).
    • 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) |

4 PRs closed without merging

From the close comments: closed without comment 1 · merged via another change 1 · other 2

  • #995 feat: add Google Gemini and Hugging Face as inference providers · other
  • #13392 fix(gemini-cli): add gemini-2.5 models to google-gemini-cli catalog and improve 404 error message · other → 7130d60, #50492
  • #29474 feat(cli): add native Antigravity OAuth provider · merged via another change → 8baa4e9, #50454
  • #54924 docs(google-gemini): explain google-gemini-cli removal and migration to gemini · closed without comment
Closed issues · 11
  • Closed#982[Feature]: Using other LLM providers than Openrouter; using LLM providers' OAuth instead of API key
  • Closed#13260[Bug]: Error: HTTP 404: Code Assist 404: Requested entity was not found.
  • Closed#29294google-gemini-cli provider: Gemini CLI / Code Assist sunsets for consumer tiers on 2026-06-18
  • Closed#32291[Feature]: Add Antigravity toolset / skill
  • Closed#44943[Feature]: Add AntiGravity support to Hermes model before Gemini CLI deprecation
  • Closed#49099[Feature]:Replace deprecated google-gemini-cli provider with antigravity-oauth (Cloud Code Assist 404)
  • Closed#49701[Bug]: google-gemini-cli provider completely broken after Code Assist sunset (June 18) — PRs unmerged for 30+ days
  • Closed#49705google-gemini-cli provider is dead since June 18 sunset — existing PRs unmerged for a month
  • Closed#50338[Feature]: Please add antigravity cli support as gemini cli is going to be obselete
  • Closed#52657[Feature]: Support Gemini Integration via Antigravity
  • Closed#54891google-gemini-cli OAuth failed with 404/429 in early June, and now appears removed without a visible OAuth replacement
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#50454
Closed issues
11
Attempts
4 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.