{"repo":"NousResearch/hermes-agent","public_id":"982","display_label":"Gemini CLI sunset, Antigravity OAuth provider transition","summary":"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.","subsystem":"agent","counts":{"members":16,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":11,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50454},"resolution":{"state":"partially_resolved","by":"pr-50454"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T23:41:30+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11899999999999995},"fix_edges":[{"issue_number":29294,"pr_number":50454,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":982,"title":"[Feature]: Using other LLM providers than Openrouter; using LLM providers' OAuth instead of API key","url":null,"state":"closed"},{"kind":"issue","number":13260,"title":"[Bug]: Error: HTTP 404: Code Assist 404: Requested entity was not found.","url":null,"state":"closed"},{"kind":"issue","number":29294,"title":"google-gemini-cli provider: Gemini CLI / Code Assist sunsets for consumer tiers on 2026-06-18","url":null,"state":"closed"},{"kind":"issue","number":32291,"title":"[Feature]: Add Antigravity toolset / skill","url":null,"state":"closed"},{"kind":"issue","number":44943,"title":"[Feature]: Add AntiGravity support to Hermes model before Gemini CLI deprecation","url":null,"state":"closed"},{"kind":"issue","number":49099,"title":"[Feature]:Replace deprecated google-gemini-cli provider with antigravity-oauth (Cloud Code Assist 404)","url":null,"state":"closed"},{"kind":"issue","number":49701,"title":"[Bug]: google-gemini-cli provider completely broken after Code Assist sunset (June 18) — PRs unmerged for 30+ days","url":null,"state":"closed"},{"kind":"issue","number":49705,"title":"google-gemini-cli provider is dead since June 18 sunset — existing PRs unmerged for a month","url":null,"state":"closed"},{"kind":"issue","number":50338,"title":"[Feature]: Please add antigravity cli support as gemini cli is going to be obselete","url":null,"state":"closed"},{"kind":"issue","number":52657,"title":"[Feature]: Support Gemini Integration via Antigravity","url":null,"state":"closed"},{"kind":"issue","number":54891,"title":"google-gemini-cli OAuth failed with 404/429 in early June, and now appears removed without a visible OAuth replacement","url":null,"state":"closed"},{"kind":"pr","number":995,"title":"feat: add Google Gemini and Hugging Face as inference providers","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":13392,"title":"fix(gemini-cli): add gemini-2.5 models to google-gemini-cli catalog and improve 404 error message","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7130d60861a9243301514bff611a9381830d59d8","number":0},{"kind":"commit","value":"7130d6086","number":0},{"kind":"pr","value":"#50492","number":50492}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":29474,"title":"feat(cli): add native Antigravity OAuth provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8baa4e997","number":0},{"kind":"pr","value":"#50454","number":50454}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50454,"title":"fix(antigravity): native google-antigravity OAuth provider (Gemini CLI sunset)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","website/docs/developer-guide/provider-runtime.md","website/docs/guides/google-gemini.md","website/docs/integrations/providers.md","website/docs/reference/cli-commands.md","website/docs/reference/environment-variables.md","website/docs/reference/faq.md","website/docs/user-guide/configuration.md","website/docs/user-guide/features/fallback-providers.md"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1394,6 +1394,21 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo\n             agent._client_log_context(),\n         )\n         return client\n+    if agent.provider == \"google-antigravity\" or str(client_kwargs.get(\"base_url\", \"\")).startswith(\"antigravity-pa://\"):\n+        from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient\n+\n+        safe_kwargs = {\n+            k: v for k, v in client_kwargs.items()\n+            if k in {\"api_key\", \"base_url\", \"default_headers\", \"project_id\", \"timeout\"}\n+        }\n+        client = AntigravityCloudCodeClient(**safe_kwargs)\n+        _ra().logger.info(\n+            \"Antigravity Code Assist client created (%s, shared=%s) %s\",\n+            reason,\n+            shared,\n+            agent._client_log_context(),\n+        )\n+        return client\n     if agent.provider == \"gemini\":\n         from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url\n "]},{"file":"agent/antigravity_cloudcode_adapter.py","hunks":["@@ -0,0 +1,164 @@\n+\"\"\"OpenAI-compatible facade for Antigravity native OAuth inference.\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Any, Dict, Iterator, List, Optional\n+\n+import httpx\n+\n+from agent import antigravity_oauth\n+from agent.antigravity_code_assist import (\n+    ANTIGRAVITY_CODE_ASSIST_ENDPOINT,\n+    CodeAssistError,\n+    ProjectContext,\n+    build_headers,\n+    resolve_project_context,\n+)\n+from agent.gemini_cloudcode_adapter import (\n+    GeminiCloudCodeClient,\n+    _GeminiStreamChunk,\n+    _gemini_http_error,\n+    _iter_sse_events,\n+    _translate_gemini_response,\n+    _translate_stream_event,\n+    build_gemini_request,\n+    wrap_code_assist_request,\n+)\n+\n+MARKER_BASE_URL = \"antigravity-pa://google\"\n+\n+\n+class AntigravityCloudCodeClient(GeminiCloudCodeClient):\n+    \"\"\"Minimal OpenAI-SDK-compatible facade over Antigravity Code Assist.\"\"\"\n+\n+    def __init__(\n+        self,\n+        *,\n+        api_key: [redacted:secret-assignment] = None,\n+        base_url: Optional[str] = None,\n+        default_headers: Optional[Dict[str, str]] = None,\n+        project_id: str = \"\",\n+        **kwargs: Any,\n+    ):\n+        super().__init__(\n+            api_key=[redacted:secret-assignment] or \"antigravity-oauth\",\n+            base_url=base_url or MARKER_BASE_URL,\n+            default_headers=default_headers,\n+            project_id=project_id,\n+            **kwargs,\n+        )\n+\n+    def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:\n+        if self._project_context is not None:\n+            return self._project_context  # type: ignore[return-value]\n+\n+        env_project = antigravity_oauth.resolve_project_id_from_env()\n+        creds = antigravity_oauth.load_credentials()\n+        stored_project = creds.project_id if creds else \"\"\n+        if stored_project:\n+            self._project_context = ProjectContext(\n+                project_id=stored_project,\n+                managed_project_id=creds.managed_project_id if creds else \"\",\n+                source=\"stored\",\n+            )\n+            return self._project_context\n+\n+        ctx = resolve_project_context(\n+            access_token,\n+            configured_project_id=self._configured_project_id,\n+            env_project_id=env_project,\n+        )\n+        if ctx.project_id or ctx.managed_project_id:\n+            antigravity_oauth.update_project_ids(\n+                project_id=ctx.project_id,\n+                managed_project_id=ctx.managed_project_id,\n+            )\n+        self._project_context = ctx\n+        return ctx\n+\n+    def _create_chat_completion(\n+        self,\n+        *,\n+        model: str = \"gemini-3-flash-agent\",\n+        messages: Optional[List[Dict[str, Any]]] = None,\n+        stream: bool = False,\n+        tools: Any = None,\n+        tool_choice: Any = None,\n+        temperature: Optional[float] = None,\n+        max_tokens: [redacted:secret-assignment] = None,\n+        top_p: Optional[float] = None,\n+        stop: Any = None,\n+        extra_body: Optional[Dict[str, Any]] = None,\n+        timeout: Any = None,\n+        **_: Any,\n+    ) -> Any:\n+        access_token = [redacted:secret-assignment]\n+        ctx = self._ensure_project_context(access_token, model)\n+\n+        thinking_config = None\n+        if isinstance(extra_body, dict):\n+            thinking_config = extra_body.get(\"thinking_config\") or extra_body.get(\"thinkingConfig\")\n+\n+        inner = build_gemini_request(\n+            messages=messages or [],\n+            tools=tools,\n+            tool_choice=tool_choice,\n+            temperature=temperature,\n+            max_tokens=[redacted:secret-assignment]\n+            top_p=top_p,\n+            stop=stop,\n+            thinking_config=thinking_config,\n+        )\n+        wrapped = wrap_code_assist_request(\n+            project_id=ctx.project_id,\n+            model=model,\n+            inner_request=inner,\n+        )\n+\n+        headers = build_headers(access_token)\n+        headers.update(self._default_headers)\n+\n+        if stream:\n+            return self._stream_completion(model=model, wrapped=wrapped, headers=headers)\n+\n+        url = f\"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent\"\n+        response = self._http.post(url, json=wrapped, headers=headers)\n+        if response.status_code != 200:\n+            raise _gemini_http_error(response)\n+        try:\n+            payload = response.json()\n+        except ValueError as exc:\n+            raise CodeAssistError(\n+                f\"Invalid JSON from Antigravity Code Assist: {exc}\",\n+                code=\"antigravity_code_assist_invalid_json\",\n+            ) from exc\n+        return _translate_gemini_response(payload, model=model)\n+\n+    def _stream_completion(\n+        self,\n+        *,\n+        model: str,\n+        wrapped: Dict[str, Any],\n+        headers: Dict[str, str],\n+    ) -> Iterator[_GeminiStreamChunk]:\n+        url = f\"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse\"\n+        stream_headers = dict(headers)\n+        stream_headers[\"Accept\"] = \"text/event-stream\"\n+\n+        def _generator() -> Iterator[_GeminiStreamChunk]:\n+            try:\n+                with self._http.stream(\"POST\", url, json=wrapped, headers=stream_headers) as response:\n+                    if response.status_code != 200:\n+                        response.read()\n+                        raise _gemini_http_error(response)\n+                    tool_call_counter: List[int] = [0]\n+                    for event in _iter_sse_events(response):\n+                        for chunk in _translate_stream_event(event, model, tool_call_counter):\n+                            yield chunk\n+            except httpx.HTTPError as exc:\n+                raise CodeAssistError(\n+                    f\"Antigravity streaming request failed: {exc}\",\n+                    code=\"antigravity_code_assist_stream_error\",\n+                ) from exc\n+\n+        return _generator()"]},{"file":"agent/antigravity_code_assist.py","hunks":["@@ -0,0 +1,286 @@\n+\"\"\"Antigravity Code Assist control-plane helpers.\n+\n+The new Antigravity CLI uses the same v1internal Code Assist family as\n+gemini-cli, but with Antigravity OAuth scopes, metadata and model catalog. This\n+module keeps that provider-specific surface separate from\n+``agent.google_code_assist``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import urllib.error\n+import urllib.request\n+import uuid\n+from dataclasses import dataclass, field\n+from typing import Any, Dict, Iterable, List, Optional\n+\n+from agent.google_code_assist import CodeAssistError\n+\n+logger = logging.getLogger(__name__)\n+\n+ANTIGRAVITY_CODE_ASSIST_ENDPOINT = \"https://daily-cloudcode-pa.sandbox.googleapis.com\"\n+ANTIGRAVITY_MODEL_ENDPOINTS = [\n+    ANTIGRAVITY_CODE_ASSIST_ENDPOINT,\n+    \"https://cloudcode-pa.googleapis.com\",\n+    \"https://autopush-cloudcode-pa.sandbox.googleapis.com\",\n+]\n+\n+ANTIGRAVITY_CLIENT_METADATA = {\n+    \"ideType\": \"ANTIGRAVITY\",\n+    \"platform\": \"PLATFORM_UNSPECIFIED\",\n+    \"pluginType\": \"GEMINI\",\n+}\n+ANTIGRAVITY_USER_AGENT = \"antigravity/1.0.0 windows/amd64\"\n+ANTIGRAVITY_X_GOOG_API_CLIENT = \"google-cloud-sdk vscode_cloudshelleditor/0.1\"\n+\n+DEFAULT_AGENT_MODEL_IDS = [\n+    \"gemini-3-flash-agent\",\n+    \"gemini-3.5-flash-low\",\n+    \"gemini-pro-agent\",\n+    \"gemini-3.1-pro-low\",\n+    \"claude-sonnet-4-6\",\n+    \"claude-opus-4-6-thinking\",\n+    \"gpt-oss-120b-medium\",\n+]\n+\n+DEPRECATED_MODEL_REPLACEMENTS = {\n+    \"gemini-3.1-pro-high\": \"gemini-pro-agent\",\n+}\n+\n+\n+@dataclass\n+class AntigravityProjectInfo:\n+    project_id: str = \"\"\n+    raw: Dict[str, Any] = field(default_factory=dict)\n+\n+\n+@dataclass\n+class ProjectContext:\n+    project_id: str = \"\"\n+    managed_project_id: str = \"\"\n+    tier_id: str = \"\"\n+    source: str = \"\"\n+\n+\n+def _client_metadata() -> Dict[str, str]:\n+    return dict(ANTIGRAVITY_CLIENT_METADATA)\n+\n+\n+def build_headers(access_token: str, *, accept: str = \"application/json\") -> Dict[str, str]:\n+    return {\n+        \"Content-Type\": \"application/json\",\n+        \"Accept\": accept,\n+        \"Authorization\": f\"Bearer {access_token}\",\n+        \"User-Agent\": ANTIGRAVITY_USER_AGENT,\n+        \"X-Goog-Api-Client\": ANTIGRAVITY_X_GOOG_API_CLIENT,\n+        \"Client-Metadata\": json.dumps(_client_metadata(), separators=(\",\", \":\")),\n+        \"x-activity-request-id\": str(uuid.uuid4()),\n+    }\n+\n+\n+def _post_json(\n+    url: str,\n+    body: Dict[str, Any],\n+    access_token: str,\n+    *,\n+    timeout: float = 30.0,\n+) -> Dict[str, Any]:\n+    data = json.dumps(body).encode(\"utf-8\")\n+    request = urllib.request.Request(\n+        url,\n+        data=data,\n+        method=\"POST\",\n+        headers=build_headers(access_token),\n+    )\n+    try:\n+        with urllib.request.urlopen(request, timeout=timeout) as response:\n+            raw = response.read().decode(\"utf-8\", errors=\"replace\")\n+            return json.loads(raw) if raw else {}\n+    except urllib.error.HTTPError as exc:\n+        detail = \"\"\n+        try:\n+            detail = exc.read().decode(\"utf-8\", errors=\"replace\")\n+        except Exception:\n+            pass\n+        raise CodeAssistError(\n+            f\"Antigravity Code Assist HTTP {exc.code}: {detail or exc.reason}\",\n+            code=f\"antigravity_code_assist_http_{exc.code}\",\n+        ) from exc\n+    except urllib.error.URLError as exc:\n+        raise CodeAssistError(\n+            f\"Antigravity Code Assist request failed: {exc}\",\n+            code=\"antigravity_code_assist_network_error\",\n+        ) from exc\n+\n+\n+def load_code_assist(\n+    access_token: str,\n+    *,\n+    project_id: str = \"\",\n+    endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,\n+) -> AntigravityProjectInfo:\n+    metadata = _client_metadata()\n+    if project_id:\n+        metadata[\"duetProject\"] = project_id\n+    body: Dict[str, Any] = {\"metadata\": metadata}\n+    if project_id:\n+        body[\"cloudaicompanionProject\"] = project_id\n+    resp = _post_json(f\"{endpoint}/v1internal:loadCodeAssist\", body, access_token)\n+    project = (\n+        str(resp.get(\"cloudaicompanionProject\") or \"\").strip()\n+        or str(resp.get(\"project\") or \"\").strip()\n+    )\n+    return AntigravityProjectInfo(project_id=project, raw=resp)\n+\n+\n+def resolve_project_context(\n+    access_token: str,\n+    *,\n+    configured_project_id: str = \"\",\n+    env_project_id: str = \"\",\n+) -> ProjectContext:\n+    if configured_project_id:\n+        return ProjectContext(project_id=configured_project_id, source=\"config\")\n+    if env_project_id:\n+        return ProjectContext(project_id=env_project_id, source=\"env\")\n+    info = load_code_assist(access_token)\n+    if info.project_id:\n+        return ProjectContext(\n+            project_id=info.project_id,\n+            managed_project_id=info.project_id,\n+            source=\"discovered\",\n+        )\n+    # Discovery returned no project (common on fresh consumer accounts that\n+    # haven't been onboarded). Fall back to the public default project so the\n+    # call chain still succeeds — mirrors the Antigravity CLI reference flow.\n+    from agent.antigravity_oauth import DEFAULT_PROJECT_ID\n+    return ProjectContext(\n+        project_id=DEFAULT_PROJECT_ID,\n+        managed_project_id=DEFAULT_PROJECT_ID,\n+        source=\"default\",\n+    )\n+\n+\n+def fetch_available_models(\n+    access_token: str,\n+    *,\n+    project_id: str = \"\",\n+    endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,\n+) -> Dict[str, Any]:\n+    body: Dict[str, Any] = {}\n+    if project_id:\n+        body[\"project\"] = project_id\n+    return _post_json(f\"{endpoint}/v1internal:fetchAvailableModels\", body, access_token)\n+\n+\n+def fetch_available_models_with_fallbacks(\n+    access_token: str,\n+    *,\n+    project_id: str = \"\",\n+    endpoints: Optional[Iterable[str]] = None,\n+) -> Dict[str, Any]:\n+    last_err: Optional[Exception] = None\n+    for endpoint in endpoints or ANTIGRAVITY_MODEL_ENDPOINTS:\n+        try:\n+            return fetch_available_models(\n+                access_token,\n+                project_id=project_id,\n+                endpoint=endpoint,\n+            )\n+        except Exception as exc:\n+            last_err = exc\n+            logger.debug(\"Antigravity fetchAvailableModels failed on %s: %s\", endpoint, exc)\n+    if last_err:\n+        raise last_err\n+    return {}\n+\n+\n+def _model_id_from_value(value: Any) -> str:\n+    if isinstance(value, str):\n+        return value.strip()\n+    if isinstance(value, dict):\n+        for key in (\"modelId\", \"model_id\", \"id\", \"name\"):\n+            candidate = str(value.get(key) or \"\").strip()\n+            if candidate:\n+                return candidate\n+    return \"\"\n+\n+\n+def _ids_from_sort(sort: Dict[str, Any]) -> List[str]:\n+    ids: List[str] = []\n+    for key in (\"modelIds\", \"model_ids\", \"models\", \"modelSorts\"):\n+        value = sort.get(key)\n+        if isinstance(value, list):\n+            for item in value:\n+                mid = _model_id_from_value(item)\n+                if mid:\n+                    ids.append(mid)\n+        elif isinstance(value, dict):\n+            mid = _model_id_from_value(value)\n+            if mid:\n+                ids.append(mid)\n+    return ids\n+\n+\n+def _is_recommended_sort(sort: Dict[str, Any]) -> bool:\n+    label = \" \".join(\n+        str(sort.get(key) or \"\")\n+        for key in (\"name\", \"displayName\", \"title\", \"category\", \"group\")\n+    ).lower()\n+    return \"recommended\" in label\n+\n+\n+def _raw_model_ids(payload: Dict[str, Any]) -> List[str]:\n+    ids: List[str] = []\n+    models = payload.get(\"models\")\n+    if isinstance(models, list):\n+        for item in models:\n+            mid = _model_id_from_value(item)\n+            if mid:\n+                ids.append(mid)\n+    return ids\n+\n+\n+def filter_agent_model_ids(ids: Iterable[str]) -> List[str]:\n+    seen: set[str] = set()\n+    filtered: List[str] = []\n+    raw = [str(mid).strip() for mid in ids if str(mid).strip()]\n+    replacements = set(DEPRECATED_MODEL_REPLACEMENTS.values())\n+    for mid in raw:\n+        if mid in seen:\n+            continue\n+        if mid.startswith((\"chat_\", \"tab_\")):\n+            continue\n+        if mid in DEPRECATED_MODEL_REPLACEMENTS and DEPRECATED_MODEL_REPLACEMENTS[mid] in raw:\n+            continue\n+        if mid in replacements and mid in seen:\n+            continue\n+        seen.add(mid)\n+        filtered.append(mid)\n+    return filtered\n+\n+\n+def parse_agent_model_ids(payload: Dict[str, Any]) -> List[str]:\n+    \"\"\"Return the user-facing Antigravity agent model list in display order.\"\"\"\n+    sorts = payload.get(\"agentModelSorts\")\n+    ordered: List[str] = []\n+    if isinstance(sorts, list):\n+        recommended = [s for s in sorts if isinstance(s, dict) and _is_recommended_sort(s)]\n+        rest = [s for s in sorts if isinstance(s, dict) and not _is_recommended_sort(s)]\n+        for sort in recommended + rest:\n+            ordered.extend(_ids_from_sort(sort))\n+\n+    if not ordered:\n+        default_id = str(payload.get(\"defaultAgentModelId\") or \"\").strip()\n+        if default_id:\n+            ordered.append(default_id)\n+        for mid in DEFAULT_AGENT_MODEL_IDS:\n+            ordered.append(mid)\n+        ordered.extend(_raw_model_ids(payload))\n+\n+    filtered = filter_agent_model_ids(ordered)\n+    if filtered:\n+        return filtered\n+    return list(DEFAULT_AGENT_MODEL_IDS)"]},{"file":"agent/antigravity_oauth.py","hunks":["@@ -0,0 +1,907 @@\n+\"\"\"Google OAuth PKCE flow for the Antigravity (google-antigravity) provider.\n+\n+Tokens are stored separately from the existing ``google-gemini-cli`` provider so\n+development and production credentials do not accidentally bleed across:\n+\n+    ~/.hermes/auth/antigravity_oauth.json\n+\n+The on-disk schema matches ``agent.google_oauth`` so the runtime resolver can\n+share the same refresh/project-id packing convention.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import base64\n+import contextlib\n+import hashlib\n+import http.server\n+import json\n+import logging\n+import os\n+import re\n+import secrets\n+import shutil\n+import stat\n+import threading\n+import time\n+import urllib.error\n+import urllib.parse\n+import urllib.request\n+import webbrowser\n+from dataclasses import dataclass\n+from pathlib import Path\n+from typing import Any, Dict, Optional, Tuple\n+\n+from hermes_constants import get_hermes_home\n+from utils import atomic_replace\n+\n+logger = logging.getLogger(__name__)\n+\n+ENV_CLIENT_ID = \"HERMES_ANTIGRAVITY_CLIENT_ID\"\n+ENV_CLIENT_SECRET = \"[redacted:secret-assignment]\"\n+ENV_CLI_PATH = \"HERMES_ANTIGRAVITY_CLI_PATH\"\n+\n+# Public Antigravity CLI desktop OAuth client. Like Google's gemini-cli\n+# credentials (see agent/google_oauth.py), this is a DESKTOP OAuth client and\n+# its \"secret\" is not confidential — installed-app clients have no\n+# secret-keeping requirement (PKCE provides the security), and these creds are\n+# baked into every copy of the Antigravity CLI. Shipping them as a fallback\n+# lets users without `agy` installed authenticate directly. Split into parts\n+# with explicit comments per the convention in google_oauth.py.\n+_PUBLIC_CLIENT_ID_PROJECT_NUM = \"1071006060591\"\n+_PUBLIC_CLIENT_ID_HASH = \"tmhssin2h21lcre235vtolojh4g403ep\"\n+_PUBLIC_CLIENT_SECRET_SUFFIX = \"[redacted:secret-assignment]\"\n+\n+_DEFAULT_CLIENT_ID = (\n+    f\"{_PUBLIC_CLIENT_ID_PROJECT_NUM}-{_PUBLIC_CLIENT_ID_HASH}\"\n+    \".apps.googleusercontent.com\"\n+)\n+_DEFAULT_CLIENT_SECRET = f\"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}\"\n+\n+# Fallback project ID when Code Assist project discovery fails entirely.\n+DEFAULT_PROJECT_ID = \"rising-fact-p41fc\"\n+\n+_CLIENT_ID_PATTERN = re.compile(\n+    r\"([0-9]{8,}-[a-z0-9]{20,}\\.apps\\.googleusercontent\\.com)\"\n+)\n+_CLIENT_SECRET_PATTERN = [redacted:secret-assignment]\"(GOCSPX-[A-Za-z0-9_-]{20,80})\")\n+_DISCOVERY_MAX_FILE_BYTES = 25 * 1024 * 1024\n+_DISCOVERY_MAX_AGY_BINARY_BYTES = 220 * 1024 * 1024\n+_DISCOVERY_MAX_FILES = 600\n+_DISCOVERY_EXTENSIONS = {\n+    \"\",\n+    \".cjs\",\n+    \".exe\",\n+    \".js\",\n+    \".json\",\n+    \".mjs\",\n+    \".node\",\n+    \".ts\",\n+}\n+_DISCOVERY_SKIP_DIRS = {\n+    \".system_generated\",\n+    \"brain\",\n+    \"conversations\",\n+    \"log\",\n+    \"logs\",\n+    \"scratch\",\n+}\n+\n+AUTH_ENDPOINT = \"https://accounts.google.com/o/oauth2/v2/auth\"\n+TOKEN_ENDPOINT = \"[redacted:secret-assignment]\"\n+USERINFO_ENDPOINT = \"https://www.googleapis.com/oauth2/v1/userinfo\"\n+\n+OAUTH_SCOPES = (\n+    \"https://www.googleapis.com/auth/cloud-platform \"\n+    \"https://www.googleapis.com/auth/userinfo.email \"\n+    \"https://www.googleapis.com/auth/userinfo.profile \"\n+    \"https://www.googleapis.com/auth/cclog \"\n+    \"https://www.googleapis.com/auth/experimentsandconfigs\"\n+)\n+\n+DEFAULT_REDIRECT_PORT = 51121\n+REDIRECT_HOST = \"localhost\"\n+CALLBACK_PATH = \"/oauth-callback\"\n+REFRESH_SKEW_SECONDS = 60\n+TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0\n+CALLBACK_WAIT_SECONDS = 300\n+LOCK_TIMEOUT_SECONDS = 30.0\n+\n+\n+class AntigravityOAuthError(RuntimeError):\n+    def __init__(self, message: str, *, code: str = \"antigravity_oauth_error\") -> None:\n+        super().__init__(message)\n+        self.code = code\n+\n+\n+def _credentials_path() -> Path:\n+    return get_hermes_home() / \"auth\" / \"antigravity_oauth.json\"\n+\n+\n+def _lock_path() -> Path:\n+    return _credentials_path().with_suffix(\".json.lock\")\n+\n+\n+_lock_state = threading.local()\n+\n+\n[redacted:email]\n+def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):\n+    depth = getattr(_lock_state, \"depth\", 0)\n+    if depth > 0:\n+        _lock_state.depth = depth + 1\n+        try:\n+            yield\n+        finally:\n+            _lock_state.depth -= 1\n+        return\n+\n+    lock_file_path = _lock_path()\n+    lock_file_path.parent.mkdir(parents=True, exist_ok=True)\n+    fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600)\n+    acquired = False\n+    try:\n+        try:\n+            import fcntl\n+        except ImportError:\n+            fcntl = None\n+\n+        if fcntl is not None:\n+            deadline = time.monotonic() + max(0.0, float(timeout_seconds))\n+            while True:\n+                try:\n+                    fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)\n+                    acquired = True\n+                    break\n+                except BlockingIOError:\n+                    if time.monotonic() >= deadline:\n+                        raise TimeoutError(\n+                            f\"Timed out acquiring Antigravity OAuth credentials lock at {lock_file_path}.\"\n+                        )\n+                    time.sleep(0.05)\n+        else:\n+            try:\n+                import msvcrt  # type: ignore[import-not-found]\n+\n+                deadline = time.monotonic() + max(0.0, float(timeout_seconds))\n+                while True:\n+                    try:\n+                        msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)\n+                        acquired = True\n+                        break\n+                    except OSError:\n+                        if time.monotonic() >= deadline:\n+                            raise TimeoutError(\n+                                f\"Timed out acquiring Antigravity OAuth credentials lock at {lock_file_path}.\"\n+                            )\n+                        time.sleep(0.05)\n+            except ImportError:\n+                acquired = True\n+\n+        _lock_state.depth = 1\n+        yield\n+    finally:\n+        try:\n+            if acquired:\n+                try:\n+                    import fcntl\n+\n+                    fcntl.flock(fd, fcntl.LOCK_UN)\n+                except ImportError:\n+                    try:\n+                        import msvcrt  # type: ignore[import-not-found]\n+\n+                        try:\n+                            msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)\n+                        except OSError:\n+                            pass\n+                    except ImportError:\n+                        pass\n+        finally:\n+            os.close(fd)\n+            _lock_state.depth = 0\n+\n+\n+_discovered_creds_cache: Dict[str, Any] = {}\n+\n+\n+def _secret_candidates(raw: str) -> list[str]:\n+    candidates: list[str] = []\n+    for length in (35, 34, 36, 33, 37, 38, 39, 40, 41, 42):\n+        if len(raw) >= length:\n+            candidates.append(raw[:length])\n+    candidates.append(raw)\n+    return list(dict.fromkeys(candidates))\n+\n+\n+def _candidate_discovery_roots() -> list[Path]:\n+    roots: list[Path] = []\n+\n+    explicit = (os.getenv(ENV_CLI_PATH) or \"\").strip()\n+    if explicit:\n+        roots.append(Path(explicit))\n+\n+    for command in (\"agy\", \"agy.exe\", \"antigravity\", \"antigravity.exe\"):\n+        found = shutil.which(command)\n+        if found:\n+            roots.append(Path(found))\n+\n+    for env_key in (\"LOCALAPPDATA\", \"APPDATA\", \"ProgramFiles\", \"ProgramFiles(x86)\"):\n+        base = os.getenv(env_key)\n+        if not base:\n+            continue\n+        base_path = Path(base)\n+        roots.extend((\n+            base_path / \"agy\",\n+            base_path / \"agy\" / \"bin\" / \"agy.exe\",\n+            base_path / \"Programs\" / \"Antigravity\",\n+            base_path / \"Programs\" / \"Antigravity CLI\",\n+            base_path / \"Google\" / \"Antigravity\",\n+            base_path / \"Google\" / \"Antigravity CLI\",\n+        ))\n+\n+    home = Path.home()\n+    for root in (\n+        home / \".gemini\" / \"antigravity-cli\",\n+        home / \".antigravitycli\",\n+        home / \".antigravity\",\n+    ):\n+        roots.append(root)\n+\n+    unique: list[Path] = []\n+    seen: set[str] = set()\n+    for root in roots:\n+        try:\n+            key = str(root.expanduser().resolve())\n+        except OSError:\n+            key = str(root.expanduser())\n+        if key not in seen:\n+            seen.add(key)\n+            unique.append(root)\n+    return unique\n+\n+\n+def _iter_discovery_files() -> list[Path]:\n+    files: list[Path] = []\n+    seen: set[str] = set()\n+\n+    def add(path: Path) -> None:\n+        if len(files) >= _DISCOVERY_MAX_FILES:\n+            return\n+        if path.suffix.lower() not in _DISCOVERY_EXTENSIONS:\n+            return\n+        try:\n+            stat_info = path.stat()\n+            max_bytes = (\n+                _DISCOVERY_MAX_AGY_BINARY_BYTES\n+                if path.name.lower() in {\"agy\", \"agy.exe\", \"antigravity\", \"antigravity.exe\"}\n+                else _DISCOVERY_MAX_FILE_BYTES\n+            )\n+            if not path.is_file() or stat_info.st_size > max_bytes:\n+                return\n+            key = str(path.resolve())\n+        except OSError:\n+            return\n+        if key in seen:\n+            return\n+        seen.add(key)\n+        files.append(path)\n+\n+    for root in _candidate_discovery_roots():\n+        if len(files) >= _DISCOVERY_MAX_FILES:\n+            break\n+        try:\n+            if root.is_file():\n+                add(root)\n+                continue\n+            if not root.is_dir():\n+                continue\n+        except OSError:\n+            continue\n+\n+        for dirpath, dirnames, filenames in os.walk(root):\n+            dirnames[:] = [\n+                d for d in dirnames\n+                if d not in _DISCOVERY_SKIP_DIRS and not d.startswith(\".git\")\n+            ]\n+            for filename in filenames:\n+                add(Path(dirpath) / filename)\n+                if len(files) >= _DISCOVERY_MAX_FILES:\n+                    break\n+            if len(files) >= _DISCOVERY_MAX_FILES:\n+                break\n+    return files\n+\n+\n+def _extract_client_credential_candidates_from_text(content: str) -> list[Tuple[str, str]]:\n+    client_ids = list(dict.fromkeys(match.group(1) for match in _CLIENT_ID_PATTERN.finditer(content)))\n+    secrets: [redacted:secret-assignment] = []\n+    for match in _CLIENT_SECRET_PATTERN.finditer(content):\n+        secrets.extend(_secret_candidates(match.group(1)))\n+    secrets = [redacted:secret-assignment]\n+    return [(client_id, secret) for client_id in client_ids for secret in secrets]\n+\n+\n+def _discover_client_credentials() -> Tuple[str, str]:\n+    if _discovered_creds_cache.get(\"resolved\"):\n+        return (\n+            _discovered_creds_cache.get(\"client_id\", \"\"),\n+            _discovered_creds_cache.get(\"client_secret\", \"\"),\n+        )\n+\n+    for path in _iter_discovery_files():\n+        try:\n+            content = path.read_bytes().decode(\"utf-8\", errors=\"ignore\")\n+        except OSError:\n+            continue\n+        candidates = _extract_client_credential_candidates_from_text(content)\n+        if candidates:\n+            client_id, client_secret = [redacted:secret-assignment]\n+            _discovered_creds_cache.update({\n+                \"client_id\": client_id,\n+                \"client_secret\": client_secret,\n+                \"candidates\": candidates,\n+                \"resolved\": \"1\",\n+            })\n+            logger.info(\"Discovered Antigravity OAuth client credentials from %s\", path)\n+            return client_id, client_secret\n+\n+    _discovered_creds_cache[\"resolved\"] = \"1\"\n+    return \"\", \"\"\n+\n+\n+def _get_client_id() -> str:\n+    env_val = (os.getenv(ENV_CLIENT_ID) or \"\").strip()\n+    if env_val:\n+        return env_val\n+    discovered, _ = _discover_client_credentials()\n+    if discovered:\n+        return discovered\n+    return _DEFAULT_CLIENT_ID\n+\n+\n+def _get_client_secret() -> str:\n+    env_val = (os.getenv(ENV_CLIENT_SECRET) or \"\").strip()\n+    if env_val:\n+        return env_val\n+    _, discovered = _discover_client_credentials()\n+    if discovered:\n+        return discovered\n+    return _DEFAULT_CLIENT_SECRET\n+\n+\n+def _iter_client_credential_candidates() -> list[Tuple[str, str]]:\n+    env_id = (os.getenv(ENV_CLIENT_ID) or \"\").strip()\n+    env_secret = [redacted:secret-assignment] or \"\").strip()\n+    if env_id and env_secret:\n+        return [(env_id, env_secret)]\n+\n+    _discover_client_credentials()\n+    cached = _discovered_creds_cache.get(\"candidates\")\n+    candidates: list[Tuple[str, str]] = []\n+    if isinstance(cached, list):\n+        candidates = [\n+            (str(client_id), str(client_secret))\n+            for client_id, client_secret in cached\n+            if client_id and client_secret\n+        ]\n+    else:\n+        client_id = str(_discovered_creds_cache.get(\"client_id\") or \"\")\n+        client_secret = [redacted:secret-assignment]\"client_secret\") or \"\")\n+        if client_id and client_secret:\n+            candidates = [(client_id, client_secret)]\n+\n+    # Always include the public baked-in default as a last-resort candidate so\n+    # users without `agy` installed can still authenticate. De-dupe in case\n+    # discovery already surfaced the same client.\n+    default_pair = (_DEFAULT_CLIENT_ID, _DEFAULT_CLIENT_SECRET)\n+    if default_pair not in candidates:\n+        candidates.append(default_pair)\n+    return candidates\n+\n+\n+def _require_client_id() -> str:\n+    client_id = _get_client_id()\n+    if not client_id:\n+        raise AntigravityOAuthError(\n+            \"Antigravity OAuth client ID is not available. Install Antigravity CLI \"\n+            \"so Hermes can discover its desktop OAuth client, set \"\n+            f\"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and \"\n+            f\"{ENV_CLIENT_SECRET} in ~/.hermes/.env.\",\n+            code=\"antigravity_oauth_client_id_missing\",\n+        )\n+    return client_id\n+\n+\n+def _require_client_secret() -> str:\n+    client_secret = [redacted:secret-assignment]\n+    if not client_secret:\n+        raise AntigravityOAuthError(\n+            \"Antigravity OAuth client secret is not available. Install Antigravity CLI \"\n+            \"so Hermes can discover its desktop OAuth client, set \"\n+            f\"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and \"\n+            f\"{ENV_CLIENT_SECRET} in ~/.hermes/.env.\",\n+            code=\"antigravity_oauth_client_secret_missing\",\n+        )\n+    return client_secret\n+\n+\n+def _require_client_credentials() -> Tuple[str, str]:\n+    candidates = _iter_client_credential_candidates()\n+    if not candidates:\n+        _require_client_id()\n+        _require_client_secret()\n+    return candidates[0]\n+\n+\n+def _generate_pkce_pair() -> Tuple[str, str]:\n+    verifier = secrets.token_urlsafe(64)\n+    digest = hashlib.sha256(verifier.encode(\"ascii\")).digest()\n+    challenge = base64.urlsafe_b64encode(digest).rstrip(b\"=\").decode(\"ascii\")\n+    return verifier, challenge\n+\n+\n+@dataclass\n+class RefreshParts:\n+    refresh_token: str\n+    project_id: str = \"\"\n+    managed_project_id: str = \"\"\n+\n+    @classmethod\n+    def parse(cls, packed: str) -> \"RefreshParts\":\n+        if not packed:\n+            return cls(refresh_token=\"\")\n+        parts = packed.split(\"|\", 2)\n+        return cls(\n+            refresh_token=[redacted:secret-assignment]\n+            project_id=parts[1] if len(parts) > 1 else \"\",\n+            managed_project_id=parts[2] if len(parts) > 2 else \"\",\n+        )\n+\n+    def format(self) -> str:\n+        if not self.refresh_token:\n+            return \"\"\n+        if not self.project_id and not self.managed_project_id:\n+            return self.refresh_token\n+        return f\"{self.refresh_token}|{self.project_id}|{self.managed_project_id}\"\n+\n+\n+@dataclass\n+class AntigravityCredentials:\n+    access_token: str\n+    refresh_token: str\n+    expires_ms: int\n+    email: str = \"\"\n+    project_id: str = \"\"\n+    managed_project_id: str = \"\"\n+\n+    def to_dict(self) -> Dict[str, Any]:\n+        return {\n+            \"refresh\": RefreshParts(\n+                refresh_token=[redacted:secret-assignment]\n+                project_id=self.project_id,\n+                managed_project_id=self.managed_project_id,\n+            ).format(),\n+            \"access\": self.access_token,\n+            \"expires\": int(self.expires_ms),\n+            \"email\": self.email,\n+        }\n+\n+    @classmethod\n+    def from_dict(cls, data: Dict[str, Any]) -> \"AntigravityCredentials\":\n+        parts = RefreshParts.parse(str(data.get(\"refresh\", \"\") or \"\"))\n+        return cls(\n+            access_token=[redacted:secret-assignment]\"access\", \"\") or \"\"),\n+            refresh_token=[redacted:secret-assignment]\n+            expires_ms=int(data.get(\"expires\", 0) or 0),\n+            email=str(data.get(\"email\", \"\") or \"\"),\n+            project_id=parts.project_id,\n+            managed_project_id=parts.managed_project_id,\n+        )\n+\n+    def access_token_expired(self, skew_seconds: int = REFRESH_SKEW_SECONDS) -> bool:\n+        if not self.access_token or not self.expires_ms:\n+            return True\n+        return (time.time() + max(0, skew_seconds)) * 1000 >= self.expires_ms\n+\n+\n+def load_credentials() -> Optional[AntigravityCredentials]:\n+    path = _credentials_path()\n+    if not path.exists():\n+        return None\n+    try:\n+        with _credentials_lock():\n+            raw = path.read_text(encoding=\"utf-8\")\n+        data = json.loads(raw)\n+    except (json.JSONDecodeError, OSError, IOError) as exc:\n+        logger.warning(\"Failed to read Antigravity OAuth credentials at %s: %s\", path, exc)\n+        return None\n+    if not isinstance(data, dict):\n+        return None\n+    creds = AntigravityCredentials.from_dict(data)\n+    if not creds.access_token:\n+        return None\n+    return creds\n+\n+\n+def save_credentials(creds: AntigravityCredentials) -> Path:\n+    path = _credentials_path()\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    try:\n+        os.chmod(path.parent, 0o700)\n+    except OSError:\n+        pass\n+    payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + \"\\n\"\n+    with _credentials_lock():\n+        tmp_path = path.with_suffix(f\".tmp.{os.getpid()}.{secrets.token_hex(4)}\")\n+        try:\n+            fd = os.open(\n+                str(tmp_path),\n+                os.O_WRONLY | os.O_CREAT | os.O_EXCL,\n+                stat.S_IRUSR | stat.S_IWUSR,\n+            )\n+            with os.fdopen(fd, \"w\", encoding=\"utf-8\") as fh:\n+                fh.write(payload)\n+                fh.flush()\n+                os.fsync(fh.fileno())\n+            atomic_replace(tmp_path, path)\n+        finally:\n+            try:\n+                if tmp_path.exists():\n+                    tmp_path.unlink()\n+            except OSError:\n+                pass\n+    return path\n+\n+\n+def clear_credentials() -> None:\n+    path = _credentials_path()\n+    with _credentials_lock():\n+        try:\n+            path.unlink()\n+        except FileNotFoundError:\n+            pass\n+        except OSError as exc:\n+            logger.warning(\"Failed to remove Antigravity OAuth credentials at %s: %s\", path, exc)\n+\n+\n+def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:\n+    body = urllib.parse.urlencode(data).encode(\"ascii\")\n+    request = urllib.request.Request(\n+        url,\n+        data=body,\n+        method=\"POST\",\n+        headers={\n+            \"Content-Type\": \"application/x-www-form-urlencoded\",\n+            \"Accept\": \"application/json\",\n+        },\n+    )\n+    try:\n+        with urllib.request.urlopen(request, timeout=timeout) as response:\n+            raw = response.read().decode(\"utf-8\", errors=\"replace\")\n+            return json.loads(raw)\n+    except urllib.error.HTTPError as exc:\n+        detail = \"\"\n+        try:\n+            detail = exc.read().decode(\"utf-8\", errors=\"replace\")\n+        except Exception:\n+            pass\n+        code = \"antigravity_oauth_token_http_error\"\n+        if \"invalid_grant\" in detail.lower():\n+            code = \"antigravity_oauth_invalid_grant\"\n+        elif \"invalid_client\" in detail.lower():\n+            code = \"antigravity_oauth_invalid_client\"\n+        raise AntigravityOAuthError(\n+            f\"Antigravity OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}\",\n+            code=code,\n+        ) from exc\n+    except urllib.error.URLError as exc:\n+        raise AntigravityOAuthError(\n+            f\"Antigravity OAuth token request failed: {exc}\",\n+            code=\"antigravity_oauth_token_network_error\",\n+        ) from exc\n+\n+\n+def exchange_code(\n+    code: str,\n+    verifier: str,\n+    redirect_uri: str,\n+    *,\n+    timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,\n+) -> Dict[str, Any]:\n+    last_error: Optional[AntigravityOAuthError] = None\n+    candidates = _iter_client_credential_candidates()\n+    if not candidates:\n+        candidates = [_require_client_credentials()]\n+    for client_id, client_secret in candidates:\n+        data = {\n+            \"grant_type\": \"authorization_code\",\n+            \"code\": code,\n+            \"code_verifier\": verifier,\n+            \"client_id\": client_id,\n+            \"client_secret\": client_secret,\n+            \"redirect_uri\": redirect_uri,\n+        }\n+        try:\n+            return _post_form(TOKEN_ENDPOINT, data, timeout)\n+        except AntigravityOAuthError as exc:\n+            last_error = exc\n+            if exc.code != \"antigravity_oauth_invalid_client\":\n+                raise\n+    if last_error is not None:\n+        raise last_error\n+    raise AntigravityOAuthError(\n+        \"Antigravity OAuth client credentials are unavailable.\",\n+        code=\"antigravity_oauth_client_missing\",\n+    )\n+\n+\n+def refresh_access_token(\n+    refresh_token: str,\n+    *,\n+    timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,\n+) -> Dict[str, Any]:\n+    if not refresh_token:\n+        raise AntigravityOAuthError(\n+            \"Cannot refresh: refresh_token is empty. Re-run OAuth login.\",\n+            code=\"antigravity_oauth_refresh_token_missing\",\n+        )\n+    last_error: Optional[AntigravityOAuthError] = None\n+    candidates = _iter_client_credential_candidates()\n+    if not candidates:\n+        candidates = [_require_client_credentials()]\n+    for client_id, client_secret in candidates:\n+        data = {\n+            \"grant_type\": \"refresh_token\",\n+            \"refresh_token\": refresh_token,\n+            \"client_id\": client_id,\n+            \"client_secret\": client_secret,\n+        }\n+        try:\n+            return _post_form(TOKEN_ENDPOINT, data, timeout)\n+        except AntigravityOAuthError as exc:\n+            last_error = exc\n+            if exc.code not in {\n+                \"antigravity_oauth_invalid_client\",\n+                \"antigravity_oauth_invalid_grant\",\n+            }:\n+                raise\n+    if last_error is not None:\n+        raise last_error\n+    raise AntigravityOAuthError(\n+        \"Antigravity OAuth client credentials are unavailable.\",\n+        code=\"antigravity_oauth_client_missing\",\n+    )\n+\n+\n+def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:\n+    try:\n+        request = urllib.request.Request(\n+            USERINFO_ENDPOINT + \"?alt=json\",\n+            headers={\"Authorization\": f\"Bearer {access_token}\"},\n+        )\n+        with urllib.request.urlopen(request, timeout=timeout) as response:\n+            raw = response.read().decode(\"utf-8\", errors=\"replace\")\n+        data = json.loads(raw)\n+        return str(data.get(\"email\", \"\") or \"\")\n+    except Exception as exc:\n+        logger.debug(\"Antigravity userinfo fetch failed (non-fatal): %s\", exc)\n+        return \"\"\n+\n+\n+_refresh_inflight: Dict[str, threading.Event] = {}\n+_refresh_inflight_lock = threading.Lock()\n+\n+\n+def get_valid_access_token(*, force_refresh: bool = False) -> str:\n+    creds = load_credentials()\n+    if creds is None:\n+        raise AntigravityOAuthError(\n+            \"No Antigravity OAuth credentials found. Run `hermes login --provider google-antigravity` first.\",\n+            code=\"antigravity_oauth_not_logged_in\",\n+        )\n+    if not force_refresh and not creds.access_token_expired():\n+        return creds.access_token\n+\n+    rt = creds.refresh_token\n+    with _refresh_inflight_lock:\n+        event = _refresh_inflight.get(rt)\n+        if event is None:\n+            event = threading.Event()\n+            _refresh_inflight[rt] = event\n+            owner = True\n+        else:\n+            owner = False\n+\n+    if not owner:\n+        event.wait(timeout=LOCK_TIMEOUT_SECONDS)\n+        fresh = load_credentials()\n+        if fresh is not None and not fresh.access_token_expired():\n+            return fresh.access_token\n+\n+    try:\n+        try:\n+            resp = refresh_access_token(rt)\n+        except AntigravityOAuthError as exc:\n+            if exc.code == \"antigravity_oauth_invalid_grant\":\n+                clear_credentials()\n+            raise\n+        new_access = str(resp.get(\"access_token\", \"\") or \"\").strip()\n+        if not new_access:\n+            raise AntigravityOAuthError(\n+                \"Refresh response did not include an access_token.\",\n+                code=\"antigravity_oauth_refresh_empty\",\n+            )\n+        creds.access_token = [redacted:secret-assignment]\n+        creds.refresh_token = [redacted:secret-assignment]\"refresh_token\", \"\") or \"\").strip() or creds.refresh_token\n+        expires_in = int(resp.get(\"expires_in\", 0) or 0)\n+        creds.expires_ms = int((time.time() + max(60, expires_in)) * 1000)\n+        save_credentials(creds)\n+        return creds.access_token\n+    finally:\n+        if owner:\n+            with _refresh_inflight_lock:\n+                _refresh_inflight.pop(rt, None)\n+            event.set()\n+\n+\n+def update_project_ids(project_id: str = \"\", managed_project_id: str = \"\") -> None:\n+    creds = load_credentials()\n+    if creds is None:\n+        return\n+    if project_id:\n+        creds.project_id = project_id\n+    if managed_project_id:\n+        creds.managed_project_id = managed_project_id\n+    save_credentials(creds)\n+\n+\n+class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):\n+    expected_state: str = \"\"\n+    captured_code: Optional[str] = None\n+    captured_error: Optional[str] = None\n+    ready: Optional[threading.Event] = None\n+\n+    def log_message(self, format: str, *args: Any) -> None:  # noqa: A002, N802\n+        logger.debug(\"Antigravity OAuth callback: \" + format, *args)\n+\n+    def do_GET(self) -> None:  # noqa: N802\n+        parsed = urllib.parse.urlparse(self.path)\n+        if parsed.path != CALLBACK_PATH:\n+            self.send_response(404)\n+            self.end_headers()\n+            return\n+\n+        params = urllib.parse.parse_qs(parsed.query)\n+        state = (params.get(\"state\") or [\"\"])[0]\n+        error = (params.get(\"error\") or [\"\"])[0]\n+        code = (params.get(\"code\") or [\"\"])[0]\n+\n+        handler_cls = type(self)\n+        if state != self.expected_state:\n+            handler_cls.captured_error = \"OAuth state mismatch.\"\n+        elif error:\n+            handler_cls.captured_error = error\n+        elif not code:\n+            handler_cls.captured_error = \"OAuth callback did not include a code.\"\n+        else:\n+            handler_cls.captured_code = code\n+\n+        ok = not handler_cls.captured_error\n+        self.send_response(200 if ok else 400)\n+        self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n+        self.end_headers()\n+        msg = \"Antigravity OAuth complete. You can return to Hermes.\" if ok else handler_cls.captured_error\n+        self.wfile.write(f\"<html><body><p>{msg}</p></body></html>\".encode(\"utf-8\"))\n+        if handler_cls.ready is not None:\n+            handler_cls.ready.set()\n+\n+\n+class _ReusableHTTPServer(http.server.HTTPServer):\n+    allow_reuse_address = True\n+\n+\n+def resolve_project_id_from_env() -> str:\n+    for key in (\"HERMES_ANTIGRAVITY_PROJECT_ID\", \"GOOGLE_CLOUD_PROJECT\", \"GOOGLE_CLOUD_PROJECT_ID\"):\n+        value = (os.getenv(key) or \"\").strip()\n+        if value:\n+            return value\n+    return \"\"\n+\n+\n+def start_oauth_flow(\n+    *,\n+    force_relogin: bool = False,\n+    open_browser: bool = True,\n+    port: int = DEFAULT_REDIRECT_PORT,\n+    project_id: str = \"\",\n+) -> AntigravityCredentials:\n+    if not force_relogin:\n+        existing = load_credentials()\n+        if existing and not existing.access_token_expired():\n+            return existing\n+\n+    verifier, challenge = _generate_pkce_pair()\n+    state = secrets.token_urlsafe(24)\n+    client_id, _ = _require_client_credentials()\n+\n+    ready = threading.Event()\n+    handler_cls = type(\"AntigravityOAuthCallbackHandler\", (_OAuthCallbackHandler,), {})\n+    handler_cls.expected_state = state\n+    handler_cls.captured_code = None\n+    handler_cls.captured_error = None\n+    handler_cls.ready = ready\n+\n+    try:\n+        server = _ReusableHTTPServer((REDIRECT_HOST, int(port)), handler_cls)\n+    except OSError:\n+        server = _ReusableHTTPServer((REDIRECT_HOST, 0), handler_cls)\n+    actual_port = int(server.server_address[1])\n+    redirect_uri = f\"http://{REDIRECT_HOST}:{actual_port}{CALLBACK_PATH}\"\n+\n+    thread = threading.Thread(target=server.serve_forever, daemon=True)\n+    thread.start()\n+    try:\n+        params = {\n+            \"client_id\": client_id,\n+            \"redirect_uri\": redirect_uri,\n+            \"response_type\": \"code\",\n+            \"scope\": OAUTH_SCOPES,\n+            \"access_type\": \"offline\",\n+            \"prompt\": \"consent\",\n+            \"state\": state,\n+            \"code_challenge\": challenge,\n+            \"code_challenge_method\": \"S256\",\n+        }\n+        auth_url = AUTH_ENDPOINT + \"?\" + urllib.parse.urlencode(params)\n+        print(\"Open this URL to authorize Antigravity OAuth:\")\n+        print(auth_url)\n+        if open_browser:\n+            webbrowser.open(auth_url)\n+        if not ready.wait(timeout=CALLBACK_WAIT_SECONDS):\n+            raise AntigravityOAuthError(\n+                \"Timed out waiting for Antigravity OAuth callback.\",\n+                code=\"antigravity_oauth_callback_timeout\",\n+            )\n+        if handler_cls.captured_error:\n+            raise AntigravityOAuthError(\n+                handler_cls.captured_error,\n+                code=\"antigravity_oauth_callback_error\",\n+            )\n+        code = handler_cls.captured_code or \"\"\n+        token = [redacted:secret-assignment] verifier, redirect_uri)\n+    finally:\n+        server.shutdown()\n+        server.server_close()\n+\n+    access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n+    refresh_token = [redacted:secret-assignment]\"refresh_token\", \"\") or \"\").strip()\n+    if not access_token or not refresh_token:\n+        raise AntigravityOAuthError(\n+            \"Antigravity OAuth response did not include both access_token and refresh_token.\",\n+            code=\"antigravity_oauth_missing_token\",\n+        )\n+    expires_in = int(token.get(\"expires_in\", 0) or 0)\n+    creds = AntigravityCredentials(\n+        access_token=[redacted:secret-assignment]\n+        refresh_token=[redacted:secret-assignment]\n+        expires_ms=int((time.time() + max(60, expires_in)) * 1000),\n+        email=_fetch_user_email(access_token),\n+        project_id=project_id,\n+    )\n+    save_credentials(creds)\n+    return creds\n+\n+\n+def run_antigravity_oauth_login_pure() -> Dict[str, Any]:\n+    creds = start_oauth_flow(\n+        force_relogin=True,\n+        project_id=resolve_project_id_from_env(),\n+    )\n+    return {\n+        \"access_token\": creds.access_token,\n+        \"refresh_token\": creds.refresh_token,\n+        \"expires_at_ms\": creds.expires_ms,\n+        \"email\": creds.email,\n+        \"project_id\": creds.project_id,\n+    }"]},{"file":"agent/gemini_cloudcode_adapter.py","hunks":["@@ -93,11 +93,14 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:\n         args = {\"_raw\": args_raw}\n     if not isinstance(args, dict):\n         args = {\"_value\": args}\n+    function_call = {\n+        \"name\": fn.get(\"name\") or \"\",\n+        \"args\": args,\n+    }\n+    if tool_call.get(\"id\"):\n+        function_call[\"id\"] = str(tool_call[\"id\"])\n     return {\n-        \"functionCall\": {\n-            \"name\": fn.get(\"name\") or \"\",\n-            \"args\": args,\n-        },\n+        \"functionCall\": function_call,\n         # Sentinel signature — matches opencode-gemini-auth's approach.\n         # Without this, Code Assist rejects function calls that originated\n         # outside its own chain.","@@ -122,12 +125,13 @@ def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:\n     except json.JSONDecodeError:\n         parsed = None\n     response = parsed if isinstance(parsed, dict) else {\"output\": content}\n-    return {\n-        \"functionResponse\": {\n-            \"name\": name,\n-            \"response\": response,\n-        },\n+    function_response = {\n+        \"name\": name,\n+        \"response\": response,\n     }\n+    if message.get(\"tool_call_id\"):\n+        function_response[\"id\"] = str(message[\"tool_call_id\"])\n+    return {\"functionResponse\": function_response}\n \n \n def _build_gemini_contents(","@@ -358,8 +362,9 @@ def _translate_gemini_response(\n                 args_str = json.dumps(fc.get(\"args\") or {}, ensure_ascii=False)\n             except (TypeError, ValueError):\n                 args_str = \"{}\"\n+            call_id = str(fc.get(\"id\") or \"\").strip() or f\"call_{uuid.uuid4().hex[:12]}\"\n             tool_calls.append(SimpleNamespace(\n-                id=f\"call_{uuid.uuid4().hex[:12]}\",\n+                id=call_id,\n                 type=\"function\",\n                 index=i,\n                 function=SimpleNamespace(name=str(fc[\"name\"]), arguments=args_str),","@@ -554,6 +559,7 @@ def _translate_stream_event(\n                 model=model,\n                 tool_call_delta={\n                     \"index\": idx,\n+                    \"id\": str(fc.get(\"id\") or \"\").strip(),\n                     \"name\": name,\n                     \"arguments\": args_str,\n                 },"]},{"file":"agent/transports/chat_completions.py","hunks":["@@ -437,7 +437,7 @@ def build_kwargs(\n                     extra_body[\"extra_body\"] = openai_compat_extra\n             elif raw_thinking_config:\n                 extra_body[\"thinking_config\"] = raw_thinking_config\n-        elif provider_name == \"google-gemini-cli\":\n+        elif provider_name in {\"google-gemini-cli\", \"google-antigravity\"}:\n             thinking_config = _build_gemini_thinking_config(model, reasoning_config)\n             if thinking_config:\n                 extra_body[\"thinking_config\"] = thinking_config"]},{"file":"hermes_cli/auth.py","hunks":["@@ -142,6 +142,9 @@\n DEFAULT_GEMINI_CLOUDCODE_BASE_URL = \"cloudcode-pa://google\"\n GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60  # refresh 60s before expiry\n \n+# Google Antigravity OAuth (Antigravity Code Assist backend)\n+DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL = \"antigravity-pa://google\"\n+\n # LM Studio's default no-auth mode still requires *some* non-empty bearer for\n # the API-key code paths (auxiliary_client, runtime resolver) to treat the\n # provider as configured. This sentinel is sent only to LM Studio, never to","@@ -212,6 +215,12 @@ class ProviderConfig:\n         auth_type=\"oauth_external\",\n         inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,\n     ),\n+    \"google-antigravity\": ProviderConfig(\n+        id=\"google-antigravity\",\n+        name=\"Google Antigravity (OAuth)\",\n+        auth_type=\"oauth_external\",\n+        inference_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,\n+    ),\n     \"lmstudio\": ProviderConfig(\n         id=\"lmstudio\",\n         name=\"LM Studio\",","@@ -1530,6 +1539,7 @@ def resolve_provider(\n         \"github-copilot-acp\": \"copilot-acp\", \"copilot-acp-agent\": \"copilot-acp\",\n         \"opencode\": \"opencode-zen\", \"zen\": \"opencode-zen\",\n         \"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\",\n+        \"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\",\n         \"hf\": \"huggingface\", \"hugging-face\": \"huggingface\", \"huggingface-hub\": \"huggingface\",\n         \"mimo\": \"xiaomi\", \"xiaomi-mimo\": \"xiaomi\",\n         \"tencent\": \"tencent-tokenhub\", \"tokenhub\": \"tencent-tokenhub\",","@@ -2246,6 +2256,72 @@ def get_gemini_oauth_auth_status() -> Dict[str, Any]:\n         \"email\": creds.email,\n         \"project_id\": creds.project_id,\n     }\n+\n+\n+def resolve_antigravity_oauth_runtime_credentials(\n+    *,\n+    force_refresh: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"Resolve runtime OAuth creds for google-antigravity.\"\"\"\n+    try:\n+        from agent.antigravity_oauth import (\n+            AntigravityOAuthError,\n+            _credentials_path,\n+            get_valid_access_token,\n+            load_credentials,\n+        )\n+    except ImportError as exc:\n+        raise AuthError(\n+            f\"agent.antigravity_oauth is not importable: {exc}\",\n+            provider=\"google-antigravity\",\n+            code=\"antigravity_oauth_module_missing\",\n+        ) from exc\n+\n+    try:\n+        access_token = [redacted:secret-assignment]\n+    except AntigravityOAuthError as exc:\n+        raise AuthError(\n+            str(exc),\n+            provider=\"google-antigravity\",\n+            code=exc.code,\n+        ) from exc\n+\n+    creds = load_credentials()\n+    return {\n+        \"provider\": \"google-antigravity\",\n+        \"base_url\": DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,\n+        \"api_key\": access_token,\n+        \"source\": \"antigravity-oauth\",\n+        \"expires_at_ms\": (creds.expires_ms if creds else None),\n+        \"auth_file\": str(_credentials_path()),\n+        \"email\": (creds.email if creds else \"\") or \"\",\n+        \"project_id\": (creds.project_id if creds else \"\") or \"\",\n+    }\n+\n+\n+def get_antigravity_oauth_auth_status() -> Dict[str, Any]:\n+    \"\"\"Return a status dict for `hermes auth list` / `hermes status`.\"\"\"\n+    try:\n+        from agent.antigravity_oauth import _credentials_path, load_credentials\n+    except ImportError:\n+        return {\"logged_in\": False, \"error\": \"agent.antigravity_oauth unavailable\"}\n+    auth_path = _credentials_path()\n+    creds = load_credentials()\n+    if creds is None or not creds.access_token:\n+        return {\n+            \"logged_in\": False,\n+            \"auth_file\": str(auth_path),\n+            \"error\": \"not logged in\",\n+        }\n+    return {\n+        \"logged_in\": True,\n+        \"auth_file\": str(auth_path),\n+        \"source\": \"antigravity-oauth\",\n+        \"api_key\": creds.access_token,\n+        \"expires_at_ms\": creds.expires_ms,\n+        \"email\": creds.email,\n+        \"project_id\": creds.project_id,\n+    }\n # Spotify auth — PKCE tokens stored in ~/.hermes/auth.json\n # =============================================================================\n ","@@ -6191,6 +6267,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n         return get_qwen_auth_status()\n     if target == \"google-gemini-cli\":\n         return get_gemini_oauth_auth_status()\n+    if target == \"google-antigravity\":\n+        return get_antigravity_oauth_auth_status()\n     if target == \"minimax-oauth\":\n         return get_minimax_oauth_auth_status()\n     if target == \"copilot-acp\":"]},{"file":"hermes_cli/auth_commands.py","hunks":["@@ -34,7 +34,7 @@\n \n \n # Providers that support OAuth login in addition to API keys.\n-_OAUTH_CAPABLE_PROVIDERS = {\"anthropic\", \"nous\", \"openai-codex\", \"xai-oauth\", \"qwen-oauth\", \"google-gemini-cli\", \"minimax-oauth\"}\n+_OAUTH_CAPABLE_PROVIDERS = {\"anthropic\", \"nous\", \"openai-codex\", \"xai-oauth\", \"qwen-oauth\", \"google-gemini-cli\", \"google-antigravity\", \"minimax-oauth\"}\n \n \n def _get_custom_provider_names() -> list:","@@ -386,6 +386,27 @@ def auth_add_command(args) -> None:\n         print(f'Added {provider} OAuth credential #{len(pool.entries())}: \"{entry.label}\"')\n         return\n \n+    if provider == \"google-antigravity\":\n+        from agent.antigravity_oauth import run_antigravity_oauth_login_pure\n+\n+        creds = run_antigravity_oauth_login_pure()\n+        label = (getattr(args, \"label\", None) or \"\").strip() or (\n+            creds.get(\"email\") or _oauth_default_label(provider, len(pool.entries()) + 1)\n+        )\n+        entry = PooledCredential(\n+            provider=provider,\n+            id=uuid.uuid4().hex[:6],\n+            label=label,\n+            auth_type=AUTH_TYPE_OAUTH,\n+            priority=0,\n+            source=f\"{SOURCE_MANUAL}:antigravity_pkce\",\n+            access_token=[redacted:secret-assignment]\"access_token\"],\n+            refresh_token=[redacted:secret-assignment]\"refresh_token\"),\n+        )\n+        pool.add_entry(entry)\n+        print(f'Added {provider} OAuth credential #{len(pool.entries())}: \"{entry.label}\"')\n+        return\n+\n     if provider == \"qwen-oauth\":\n         creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)\n         auth_mod._mark_qwen_oauth_active(creds)"]},{"file":"hermes_cli/config.py","hunks":["@@ -3100,6 +3100,38 @@ def _ensure_hermes_home_managed(home: Path):\n         \"category\": \"provider\",\n         \"advanced\": True,\n     },\n+    \"HERMES_ANTIGRAVITY_CLIENT_ID\": {\n+        \"description\": \"Google OAuth client ID for google-antigravity (optional; discovered from agy when omitted)\",\n+        \"prompt\": \"Antigravity OAuth client ID (optional — leave empty to discover from agy)\",\n+        \"url\": \"https://console.cloud.google.com/apis/credentials\",\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n+    \"HERMES_ANTIGRAVITY_CLIENT_SECRET\": {\n+        \"description\": \"Google OAuth client secret for google-antigravity (optional)\",\n+        \"prompt\": \"Antigravity OAuth client secret (optional)\",\n+        \"url\": \"https://console.cloud.google.com/apis/credentials\",\n+        \"password\": True,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n+    \"HERMES_ANTIGRAVITY_CLI_PATH\": {\n+        \"description\": \"Path to agy/Antigravity CLI for OAuth client credential discovery\",\n+        \"prompt\": \"Antigravity CLI path (leave empty to search PATH/default locations)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n+    \"HERMES_ANTIGRAVITY_PROJECT_ID\": {\n+        \"description\": \"GCP project ID for Antigravity OAuth (auto-discovered when omitted)\",\n+        \"prompt\": \"GCP project ID for Antigravity OAuth (leave empty to auto-discover)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n     \"OPENCODE_ZEN_API_KEY\": {\n         \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n         \"prompt\": \"OpenCode Zen API key\","]},{"file":"hermes_cli/main.py","hunks":["@@ -603,6 +603,7 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:\n     _model_flow_qwen_oauth,\n     _model_flow_minimax_oauth,\n     _model_flow_google_gemini_cli,\n+    _model_flow_google_antigravity,\n     _model_flow_custom,\n     _model_flow_azure_foundry,\n     _model_flow_named_custom,","@@ -3074,6 +3075,8 @@ def _active_custom_key_from_base_url() -> str:\n         _model_flow_minimax_oauth(config, current_model, args=args)\n     elif selected_provider == \"google-gemini-cli\":\n         _model_flow_google_gemini_cli(config, current_model)\n+    elif selected_provider == \"google-antigravity\":\n+        _model_flow_google_antigravity(config, current_model)\n     elif selected_provider == \"copilot-acp\":\n         _model_flow_copilot_acp(config, current_model)\n     elif selected_provider == \"copilot\":","@@ -3603,14 +3606,6 @@ def _prompt_provider_choice(choices, *, default=0, title=\"Select provider:\"):\n ]\n \n \n-\n-\n-\n-\n-\n-\n-\n-\n def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = \"\") -> Optional[str]:\n     \"\"\"Prompt for a custom provider API mode.\n ","@@ -11248,6 +11243,24 @@ def cmd_logs(args):\n         since=getattr(args, \"since\", None),\n         component=getattr(args, \"component\", None),\n     )\n+\n+\n+def _build_provider_choices() -> list[str]:\n+    \"\"\"Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'.\"\"\"\n+    try:\n+        from hermes_cli.models import CANONICAL_PROVIDERS as _cp\n+        return [\"auto\"] + [p.slug for p in _cp]\n+    except Exception:\n+        # Fallback: static list guarantees the CLI always works\n+        return [\n+            \"auto\", \"openrouter\", \"nous\", \"openai-codex\", \"xai-oauth\", \"copilot-acp\", \"copilot\",\n+            \"anthropic\", \"gemini\", \"google-gemini-cli\", \"google-antigravity\", \"xai\", \"bedrock\", \"azure-foundry\",\n+            \"ollama-cloud\", \"huggingface\", \"zai\", \"kimi-coding\", \"kimi-coding-cn\",\n+            \"stepfun\", \"minimax\", \"minimax-cn\", \"kilocode\", \"novita\", \"xiaomi\", \"arcee\",\n+            \"nvidia\", \"deepseek\", \"alibaba\", \"qwen-oauth\", \"opencode-zen\", \"opencode-go\",\n+        ]\n+\n+\n # Top-level subcommands that argparse knows about WITHOUT running plugin\n # discovery.  Used to short-circuit eager plugin imports (which can take\n # 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the"]},{"file":"hermes_cli/model_setup_flows.py","hunks":["@@ -712,6 +712,64 @@ def _model_flow_google_gemini_cli(_config, current_model=\"\"):\n     else:\n         print(\"No change.\")\n \n+\n+def _model_flow_google_antigravity(_config, current_model=\"\"):\n+    \"\"\"Google Antigravity OAuth via Antigravity Code Assist.\n+\n+    Antigravity is Google's consumer successor to the Gemini CLI. It reuses the\n+    Code Assist backend with a distinct OAuth client + scopes. Leaves the\n+    `google-gemini-cli` provider (Enterprise Code Assist) untouched.\n+    \"\"\"\n+    from hermes_cli.auth import (\n+        DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,\n+        get_antigravity_oauth_auth_status,\n+        resolve_antigravity_oauth_runtime_credentials,\n+        _prompt_model_selection,\n+        _save_model_choice,\n+        _update_config_for_provider,\n+    )\n+    from hermes_cli.models import provider_model_ids\n+\n+    status = get_antigravity_oauth_auth_status()\n+    if not status.get(\"logged_in\"):\n+        try:\n+            from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow\n+\n+            env_project = resolve_project_id_from_env()\n+            start_oauth_flow(force_relogin=True, project_id=env_project)\n+        except Exception as exc:\n+            print(f\"OAuth login failed: {exc}\")\n+            return\n+\n+    try:\n+        creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False)\n+        project_id = creds.get(\"project_id\", \"\")\n+        if project_id:\n+            print(f\"  Using Antigravity project: {project_id}\")\n+    except Exception as exc:\n+        print(f\"Failed to resolve Antigravity credentials: {exc}\")\n+        return\n+\n+    models = provider_model_ids(\"google-antigravity\")\n+    default = current_model or (models[0] if models else \"gemini-3-flash-agent\")\n+    selected = _prompt_model_selection(\n+        models,\n+        current_model=default,\n+        confirm_provider=\"google-antigravity\",\n+        confirm_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,\n+    )\n+    if selected:\n+        _save_model_choice(selected)\n+        _update_config_for_provider(\n+            \"google-antigravity\", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL\n+        )\n+        print(\n+            f\"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)\"\n+        )\n+    else:\n+        print(\"No change.\")\n+\n+\n def _model_flow_custom(config):\n     \"\"\"Custom endpoint: collect URL, API key, and model name.\n "]},{"file":"hermes_cli/models.py","hunks":["@@ -276,6 +276,15 @@ def _xai_curated_models() -> list[str]:\n         \"gemini-3-flash-preview\",\n         \"gemini-3.5-flash\",\n     ],\n+    \"google-antigravity\": [\n+        \"gemini-3-flash-agent\",\n+        \"gemini-3.5-flash-low\",\n+        \"gemini-pro-agent\",\n+        \"gemini-3.1-pro-low\",\n+        \"claude-sonnet-4-6\",\n+        \"claude-opus-4-6-thinking\",\n+        \"gpt-oss-120b-medium\",\n+    ],\n     \"zai\": [\n         \"glm-5.2\",\n         \"glm-5.1\",","@@ -1029,6 +1038,7 @@ class ProviderEntry(NamedTuple):\n     ProviderEntry(\"huggingface\",    \"Hugging Face\",             \"Hugging Face Inference Providers\"),\n     ProviderEntry(\"gemini\",         \"Google AI Studio\",         \"Google AI Studio (Native Gemini API)\"),\n     ProviderEntry(\"google-gemini-cli\", \"Google Gemini (OAuth)\",   \"Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)\"),\n+    ProviderEntry(\"google-antigravity\", \"Google Antigravity (OAuth)\", \"Google Antigravity via OAuth + Code Assist (Gemini 3.5/3.1, Claude, GPT-OSS where entitled)\"),\n     ProviderEntry(\"deepseek\",       \"DeepSeek\",                 \"DeepSeek (V3, R1, coder, direct API)\"),\n     ProviderEntry(\"xai\",            \"xAI\",                      \"xAI Grok (Direct API)\"),\n     ProviderEntry(\"zai\",            \"Z.AI / GLM\",               \"Z.AI / GLM (Zhipu direct API)\"),","@@ -1222,6 +1232,12 @@ def group_providers(slugs):\n     \"qwen-portal\": \"qwen-oauth\",\n     \"gemini-cli\": \"google-gemini-cli\",\n     \"gemini-oauth\": \"google-gemini-cli\",\n+    \"antigravity\": \"google-antigravity\",\n+    \"antigravity-oauth\": \"google-antigravity\",\n+    \"antigravity-cli\": \"google-antigravity\",\n+    \"google-antigravity-oauth\": \"google-antigravity\",\n+    \"agy\": \"google-antigravity\",\n+    \"agy-cli\": \"google-antigravity\",\n     \"hf\": \"huggingface\",\n     \"hugging-face\": \"huggingface\",\n     \"huggingface-hub\": \"huggingface\",","@@ -1788,6 +1804,15 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:\n     {\"nous\", \"openrouter\", \"copilot\", \"kilocode\"}\n )\n \n+# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models\n+# (e.g. google-antigravity serves Claude / Gemini / GPT-OSS where the account\n+# is entitled). For bare short-alias resolution (`sonnet`, `opus`, ...) these\n+# must NOT hijack the alias away from the model's native vendor provider\n+# (`anthropic`, `gemini`, ...). They're tried only as a last resort, after\n+# every native-vendor catalog. They are NOT aggregators (an explicit switch TO\n+# them is still valid), so they stay out of _AGGREGATOR_PROVIDERS.\n+_BORROWED_MODEL_PROVIDERS = frozenset({\"google-antigravity\"})\n+\n \n def _resolve_static_model_alias(\n     name_lower: str,","@@ -1825,7 +1850,11 @@ def _match(provider: str) -> Optional[str]:\n             return provider, matched\n \n     for provider in _PROVIDER_MODELS:\n-        if provider in current_keys or provider in _AGGREGATOR_PROVIDERS:\n+        if (\n+            provider in current_keys\n+            or provider in _AGGREGATOR_PROVIDERS\n+            or provider in _BORROWED_MODEL_PROVIDERS\n+        ):\n             continue\n         if matched := _match(provider):\n             return provider, matched","@@ -1834,6 +1863,13 @@ def _match(provider: str) -> Optional[str]:\n         if provider in current_keys and (matched := _match(provider)):\n             return provider, matched\n \n+    # Last resort: providers that re-expose other vendors' models (e.g.\n+    # google-antigravity serving Claude). Only reached when no native-vendor\n+    # catalog matched — so `sonnet` resolves to anthropic, not antigravity.\n+    for provider in _BORROWED_MODEL_PROVIDERS:\n+        if provider in current_keys and (matched := _match(provider)):\n+            return provider, matched\n+\n     return None\n \n ","@@ -1880,11 +1916,23 @@ def detect_static_provider_for_model(\n \n     # --- Step 1: check static provider catalogs for a direct match ---\n     for pid, models in _PROVIDER_MODELS.items():\n-        if pid in current_keys or pid in _AGGREGATOR_PROVIDERS:\n+        if (\n+            pid in current_keys\n+            or pid in _AGGREGATOR_PROVIDERS\n+            or pid in _BORROWED_MODEL_PROVIDERS\n+        ):\n             continue\n         if any(name_lower == m.lower() for m in models):\n             return (pid, name)\n \n+    # Borrow-list providers (re-expose other vendors' models) only after every\n+    # native-vendor catalog, and only when one is the current provider.\n+    for pid in _BORROWED_MODEL_PROVIDERS:\n+        if pid in current_keys:\n+            continue\n+        if any(name_lower == m.lower() for m in _PROVIDER_MODELS.get(pid, [])):\n+            return (pid, name)\n+\n     return None\n \n ","@@ -2192,6 +2240,32 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:\n     return merged\n \n \n+def _fetch_antigravity_models(*, force_refresh: bool = False) -> list[str]:\n+    try:\n+        from agent import antigravity_oauth\n+        from agent.antigravity_code_assist import (\n+            fetch_available_models_with_fallbacks,\n+            load_code_assist,\n+            parse_agent_model_ids,\n+        )\n+        from hermes_cli.auth import resolve_antigravity_oauth_runtime_credentials\n+\n+        creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=force_refresh)\n+        access_token = [redacted:secret-assignment]\"api_key\") or \"\").strip()\n+        project_id = str(creds.get(\"project_id\") or \"\").strip()\n+        if not access_token:\n+            return []\n+        if not project_id:\n+            info = load_code_assist(access_token)\n+            project_id = info.project_id\n+            if project_id:\n+                antigravity_oauth.update_project_ids(project_id=project_id, managed_project_id=project_id)\n+        payload = fetch_available_models_with_fallbacks(access_token, project_id=project_id)\n+        return parse_agent_model_ids(payload)\n+    except Exception:\n+        return []\n+\n+\n def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:\n     \"\"\"Return the best known model catalog for a provider.\n ","@@ -2222,6 +2296,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)\n         return get_codex_model_ids(access_token=[redacted:secret-assignment]\n     if normalized == \"xai-oauth\":\n         return list(_PROVIDER_MODELS.get(\"xai-oauth\", _PROVIDER_MODELS.get(\"xai\", [])))\n+    if normalized == \"google-antigravity\":\n+        live = _fetch_antigravity_models(force_refresh=force_refresh)\n+        if live:\n+            return live\n     if normalized in {\"copilot\", \"copilot-acp\"}:\n         try:\n             live = _fetch_github_models(_resolve_copilot_catalog_api_key())"]},{"file":"hermes_cli/providers.py","hunks":["@@ -81,6 +81,11 @@ class HermesOverlay:\n         auth_type=\"oauth_external\",\n         base_url_override=\"cloudcode-pa://google\",\n     ),\n+    \"google-antigravity\": HermesOverlay(\n+        transport=\"openai_chat\",\n+        auth_type=\"oauth_external\",\n+        base_url_override=\"antigravity-pa://google\",\n+    ),\n     \"lmstudio\": HermesOverlay(\n         transport=\"openai_chat\",\n         auth_type=\"api_key\",","@@ -314,6 +319,13 @@ class ProviderDef:\n     \"gemini-cli\": \"google-gemini-cli\",\n     \"gemini-oauth\": \"google-gemini-cli\",\n \n+    # google-antigravity (OAuth + Antigravity Code Assist)\n+    \"antigravity\": \"google-antigravity\",\n+    \"antigravity-oauth\": \"google-antigravity\",\n+    \"antigravity-cli\": \"google-antigravity\",\n+    \"google-antigravity-oauth\": \"google-antigravity\",\n+    \"agy\": \"google-antigravity\",\n+    \"agy-cli\": \"google-antigravity\",\n \n     # huggingface\n     \"hf\": \"huggingface\","]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -27,6 +27,7 @@\n     resolve_xai_oauth_runtime_credentials,\n     resolve_qwen_runtime_credentials,\n     resolve_gemini_oauth_runtime_credentials,\n+    resolve_antigravity_oauth_runtime_credentials,\n     resolve_api_key_provider_credentials,\n     resolve_external_process_provider_credentials,\n     has_usable_secret,","@@ -334,6 +335,9 @@ def _resolve_runtime_from_pool_entry(\n     elif provider == \"google-gemini-cli\":\n         api_mode = \"chat_completions\"\n         base_url = base_url or \"cloudcode-pa://google\"\n+    elif provider == \"google-antigravity\":\n+        api_mode = \"chat_completions\"\n+        base_url = base_url or \"antigravity-pa://google\"\n     elif provider == \"minimax-oauth\":\n         # MiniMax OAuth tokens are valid only against the Anthropic Messages\n         # compatible endpoint. Do not honor stale model.api_mode values from a","@@ -1634,6 +1638,26 @@ def resolve_runtime_provider(\n             logger.info(\"Google Gemini OAuth credentials failed; \"\n                         \"falling through to next provider.\")\n \n+    if provider == \"google-antigravity\":\n+        try:\n+            creds = resolve_antigravity_oauth_runtime_credentials()\n+            return {\n+                \"provider\": \"google-antigravity\",\n+                \"api_mode\": \"chat_completions\",\n+                \"base_url\": creds.get(\"base_url\", \"\"),\n+                \"api_key\": creds.get(\"api_key\", \"\"),\n+                \"source\": creds.get(\"source\", \"antigravity-oauth\"),\n+                \"expires_at_ms\": creds.get(\"expires_at_ms\"),\n+                \"email\": creds.get(\"email\", \"\"),\n+                \"project_id\": creds.get(\"project_id\", \"\"),\n+                \"requested_provider\": requested_provider,\n+            }\n+        except AuthError:\n+            if requested_provider != \"auto\":\n+                raise\n+            logger.info(\"Google Antigravity OAuth credentials failed; \"\n+                        \"falling through to next provider.\")\n+\n     if provider == \"copilot-acp\":\n         creds = resolve_external_process_provider_credentials(provider)\n         return {"]},{"file":"plugins/model-providers/gemini/__init__.py","hunks":["@@ -2,6 +2,7 @@\n \n gemini:            Google AI Studio (API key) — uses GeminiNativeClient\n google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient\n+google-antigravity: Google Antigravity Code Assist (OAuth) — uses AntigravityCloudCodeClient\n \n Both report api_mode=\"chat_completions\" but use custom native clients\n that bypass the standard OpenAI transport. The profile captures auth","@@ -68,5 +69,22 @@ def build_extra_body(\n     auth_type=\"oauth_external\",\n )\n \n+google_antigravity = GeminiProfile(\n+    name=\"google-antigravity\",\n+    aliases=(\n+        \"antigravity\",\n+        \"antigravity-oauth\",\n+        \"antigravity-cli\",\n+        \"google-antigravity-oauth\",\n+        \"agy\",\n+        \"agy-cli\",\n+    ),\n+    api_mode=\"chat_completions\",\n+    env_vars=(),  # OAuth — no API key\n+    base_url=\"antigravity-pa://google\",  # Antigravity Code Assist internal scheme\n+    auth_type=\"oauth_external\",\n+)\n+\n register_provider(gemini)\n register_provider(google_gemini_cli)\n+register_provider(google_antigravity)"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"pmos69\",  # PR #29474 salvage (native Antigravity OAuth provider; Gemini CLI sunset #29294/#49701)\n     \"[redacted:email]\": \"orbisai0security\",  # PR #9560 salvage (session.py path-traversal guard, V-009)\n     \"[redacted:email]\": \"panghuer023\",  # PR #37994 salvage (interrupt unblocks pending gateway approval; #8697)\n     \"[redacted:email]\": \"natehale\",  # PR #48678 salvage (typing indicator lingers after final reply)"]},{"file":"tests/agent/test_antigravity_cloudcode.py","hunks":["@@ -0,0 +1,405 @@\n+\"\"\"Tests for the google-antigravity OAuth + Antigravity Code Assist provider.\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+import stat\n+import time\n+import threading\n+import urllib.parse\n+from io import BytesIO\n+from pathlib import Path\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _isolate_env(monkeypatch, tmp_path):\n+    home = tmp_path / \".hermes\"\n+    home.mkdir(parents=True)\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    for key in (\n+        \"HERMES_ANTIGRAVITY_CLIENT_ID\",\n+        \"HERMES_ANTIGRAVITY_CLIENT_SECRET\",\n+        \"HERMES_ANTIGRAVITY_CLI_PATH\",\n+        \"HERMES_ANTIGRAVITY_PROJECT_ID\",\n+        \"GOOGLE_CLOUD_PROJECT\",\n+        \"GOOGLE_CLOUD_PROJECT_ID\",\n+        \"LOCALAPPDATA\",\n+        \"APPDATA\",\n+        \"ProgramFiles\",\n+        \"ProgramFiles(x86)\",\n+    ):\n+        monkeypatch.delenv(key, raising=False)\n+    monkeypatch.setattr(\"shutil.which\", lambda _: None)\n+    try:\n+        from agent import antigravity_oauth\n+\n+        antigravity_oauth._discovered_creds_cache.clear()\n+    except Exception:\n+        pass\n+    return home\n+\n+\n+class TestAntigravityCredentials:\n+    def test_save_load_uses_separate_file_and_0600_permissions(self):\n+        from agent.antigravity_oauth import (\n+            AntigravityCredentials,\n+            _credentials_path,\n+            load_credentials,\n+            save_credentials,\n+        )\n+\n+        save_credentials(AntigravityCredentials(\n+            access_token=\"at\",\n+            refresh_token=\"rt\",\n+            expires_ms=int((time.time() + 3600) * 1000),\n+            email=\"[redacted:email]\",\n+            project_id=\"proj-123\",\n+        ))\n+\n+        assert _credentials_path().name == \"antigravity_oauth.json\"\n+        loaded = load_credentials()\n+        assert loaded is not None\n+        assert loaded.refresh_token == \"rt\"\n+        assert loaded.project_id == \"proj-123\"\n+        if os.name != \"nt\":\n+            assert stat.S_IMODE(_credentials_path().stat().st_mode) == 0o600\n+\n+    def test_env_override_client_id(self, monkeypatch):\n+        from agent.antigravity_oauth import _get_client_id\n+\n+        monkeypatch.setenv(\"HERMES_ANTIGRAVITY_CLIENT_ID\", \"custom.apps.googleusercontent.com\")\n+        assert _get_client_id() == \"custom.apps.googleusercontent.com\"\n+\n+    def test_env_override_client_secret(self, monkeypatch):\n+        from agent.antigravity_oauth import _get_client_secret\n+\n+        monkeypatch.setenv(\"HERMES_ANTIGRAVITY_CLIENT_SECRET\", \"custom-secret\")\n+        assert _get_client_secret() == \"custom-secret\"\n+\n+    def test_discovers_client_credentials_from_configured_agy_path(self, tmp_path, monkeypatch):\n+        from agent import antigravity_oauth\n+\n+        fake_client_id = (\n+            \"1071006060591-\"\n+            + \"fakefakefakefakefakefakefake\"\n+            + \".apps.google\"\n+            + \"usercontent.com\"\n+        )\n+        fake_client_secret = \"GOC\" + \"SPX-\" + \"fake-secret-value-placeholde\"\n+        fake_agy = tmp_path / \"agy.exe\"\n+        fake_agy.write_text(\n+            f'oauthClientId=\"{fake_client_id}\";\\n'\n+            f'oauthClientSecret=\"[redacted:secret-assignment]\";\\n',\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setenv(\"HERMES_ANTIGRAVITY_CLI_PATH\", str(fake_agy))\n+        antigravity_oauth._discovered_creds_cache.clear()\n+\n+        assert antigravity_oauth._get_client_id().startswith(\"1071006060591-\")\n+        assert antigravity_oauth._get_client_secret() == fake_client_secret\n+\n+    def test_missing_discovery_falls_back_to_public_default(self, monkeypatch):\n+        # With no env override and no discoverable agy install, the public\n+        # baked-in Antigravity desktop OAuth client is used as the floor so\n+        # users without `agy` installed can still authenticate (PKCE makes the\n+        # installed-app \"secret\" non-confidential, same as gemini-cli).\n+        from agent import antigravity_oauth\n+        from agent.antigravity_oauth import (\n+            _DEFAULT_CLIENT_ID,\n+            _DEFAULT_CLIENT_SECRET,\n+            _require_client_id,\n+        )\n+\n+        monkeypatch.delenv(\"HERMES_ANTIGRAVITY_CLIENT_ID\", raising=False)\n+        monkeypatch.delenv(\"HERMES_ANTIGRAVITY_CLIENT_SECRET\", raising=False)\n+        monkeypatch.delenv(\"HERMES_ANTIGRAVITY_CLI_PATH\", raising=False)\n+        antigravity_oauth._discovered_creds_cache.clear()\n+\n+        assert _require_client_id() == _DEFAULT_CLIENT_ID\n+        assert antigravity_oauth._get_client_secret() == _DEFAULT_CLIENT_SECRET\n+        assert _DEFAULT_CLIENT_ID.startswith(\"1071006060591-\")\n+\n+    def test_pkce_challenge_is_s256(self):\n+        import base64\n+        import hashlib\n+\n+        from agent.antigravity_oauth import _generate_pkce_pair\n+\n+        verifier, challenge = _generate_pkce_pair()\n+        expected = base64.urlsafe_b64encode(\n+            hashlib.sha256(verifier.encode(\"ascii\")).digest()\n+        ).rstrip(b\"=\").decode(\"ascii\")\n+        assert challenge == expected\n+        assert 43 <= len(verifier) <= 128\n+\n+    def test_exchange_code_posts_pkce_payload(self, monkeypatch):\n+        from agent import antigravity_oauth\n+\n+        captured = {}\n+\n+        def fake_post(url, data, timeout):\n+            captured.update({\"url\": url, \"data\": data, \"timeout\": timeout})\n+            return {\"access_token\": \"at\"}\n+\n+        monkeypatch.setattr(antigravity_oauth, \"_post_form\", fake_post)\n+        monkeypatch.setenv(\"HERMES_ANTIGRAVITY_CLIENT_ID\", \"client.apps.googleusercontent.com\")\n+        monkeypatch.setenv(\"HERMES_ANTIGRAVITY_CLIENT_SECRET\", \"secret\")\n+\n+        assert antigravity_oauth.exchange_code(\"code\", \"verifier\", \"http://localhost/cb\") == {\n+            \"access_token\": \"at\"\n+        }\n+        assert captured[\"url\"] == antigravity_oauth.TOKEN_ENDPOINT\n+        assert captured[\"data\"][\"grant_type\"] == \"authorization_code\"\n+        assert captured[\"data\"][\"code_verifier\"] == \"verifier\"\n+        assert captured[\"data\"][\"redirect_uri\"] == \"http://localhost/cb\"\n+        assert captured[\"data\"][\"client_id\"] == \"client.apps.googleusercontent.com\"\n+        assert captured[\"data\"][\"client_secret\"] == \"secret\"\n+\n+    def test_refresh_tries_discovered_client_secret_candidates(self, monkeypatch):\n+        from agent import antigravity_oauth\n+        from agent.antigravity_oauth import AntigravityOAuthError\n+\n+        calls = []\n+        monkeypatch.setattr(\n+            antigravity_oauth,\n+            \"_iter_client_credential_candidates\",\n+            lambda: [\n+                (\"client.apps.googleusercontent.com\", \"wrong-secret\"),\n+                (\"client.apps.googleusercontent.com\", \"right-secret\"),\n+            ],\n+        )\n+\n+        def fake_post(url, data, timeout):\n+            calls.append(data[\"client_secret\"])\n+            if data[\"client_secret\"] == \"wrong-secret\":\n+                raise AntigravityOAuthError(\n+                    \"invalid client\",\n+                    code=\"antigravity_oauth_invalid_client\",\n+                )\n+            return {\"access_token\": \"new-token\", \"expires_in\": 3600}\n+\n+        monkeypatch.setattr(antigravity_oauth, \"_post_form\", fake_post)\n+\n+        assert antigravity_oauth.refresh_access_token(\"refresh-token\")[\"access_token\"] == \"new-token\"\n+        assert calls == [\"wrong-secret\", \"right-secret\"]\n+\n+    def test_invalid_grant_refresh_clears_credentials(self, monkeypatch):\n+        from agent import antigravity_oauth\n+        from agent.antigravity_oauth import (\n+            AntigravityCredentials,\n+            AntigravityOAuthError,\n+            load_credentials,\n+            save_credentials,\n+        )\n+\n+        save_credentials(AntigravityCredentials(\n+            access_token=\"[redacted:secret-assignment]\",\n+            refresh_token=\"rt\",\n+            expires_ms=int((time.time() - 3600) * 1000),\n+        ))\n+\n+        def invalid_grant(_refresh_token):\n+            raise AntigravityOAuthError(\"revoked\", code=\"antigravity_oauth_invalid_grant\")\n+\n+        monkeypatch.setattr(antigravity_oauth, \"refresh_access_token\", invalid_grant)\n+        with pytest.raises(AntigravityOAuthError, match=\"revoked\"):\n+            antigravity_oauth.get_valid_access_token()\n+        assert load_credentials() is None\n+\n+    def test_callback_handler_captures_code_on_handler_class(self):\n+        from agent.antigravity_oauth import CALLBACK_PATH, _OAuthCallbackHandler\n+\n+        handler_cls = type(\"TestAntigravityOAuthCallbackHandler\", (_OAuthCallbackHandler,), {})\n+        handler_cls.expected_state = \"state-123\"\n+        handler_cls.captured_code = None\n+        handler_cls.captured_error = None\n+        handler_cls.ready = threading.Event()\n+\n+        handler = handler_cls.__new__(handler_cls)\n+        handler.path = CALLBACK_PATH + \"?\" + urllib.parse.urlencode({\n+            \"state\": \"state-123\",\n+            \"code\": \"auth-code\",\n+        })\n+        handler.wfile = BytesIO()\n+        responses = []\n+        headers = []\n+        handler.send_response = lambda code: responses.append(code)\n+        handler.send_header = lambda key, value: headers.append((key, value))\n+        handler.end_headers = lambda: None\n+\n+        handler.do_GET()\n+\n+        assert responses == [200]\n+        assert handler_cls.captured_code == \"auth-code\"\n+        assert handler_cls.captured_error is None\n+        assert handler_cls.ready.is_set()\n+        assert \"captured_code\" not in handler.__dict__\n+\n+\n+class TestAntigravityModelCatalog:\n+    def test_parse_agent_model_ids_prefers_recommended_group(self):\n+        from agent.antigravity_code_assist import parse_agent_model_ids\n+\n+        payload = {\n+            \"defaultAgentModelId\": \"gemini-3-flash-agent\",\n+            \"agentModelSorts\": [\n+                {\n+                    \"displayName\": \"Experimental\",\n+                    \"modelIds\": [\"tab_flash_lite_preview\", \"chat_23310\"],\n+                },\n+                {\n+                    \"displayName\": \"Recommended\",\n+                    \"modelIds\": [\n+                        \"gemini-3-flash-agent\",\n+                        \"gemini-3.5-flash-low\",\n+                        \"gemini-3.1-pro-high\",\n+                        \"gemini-pro-agent\",\n+                        \"claude-sonnet-4-6\",\n+                    ],\n+                },\n+            ],\n+            \"models\": [{\"id\": \"gpt-oss-120b-medium\"}],\n+        }\n+\n+        assert parse_agent_model_ids(payload) == [\n+            \"gemini-3-flash-agent\",\n+            \"gemini-3.5-flash-low\",\n+            \"gemini-pro-agent\",\n+            \"claude-sonnet-4-6\",\n+        ]\n+\n+    def test_headers_include_antigravity_metadata(self):\n+        from agent.antigravity_code_assist import build_headers\n+\n+        headers = build_headers(\"tok\")\n+        assert headers[\"Authorization\"] == \"Bearer tok\"\n+        assert headers[\"User-Agent\"].startswith(\"antigravity/\")\n+        assert headers[\"X-Goog-Api-Client\"] == \"google-cloud-sdk vscode_cloudshelleditor/0.1\"\n+        metadata = json.loads(headers[\"Client-Metadata\"])\n+        assert metadata[\"ideType\"] == \"ANTIGRAVITY\"\n+        assert metadata[\"platform\"] == \"PLATFORM_UNSPECIFIED\"\n+\n+\n+class TestAntigravityClient:\n+    def test_client_exposes_openai_interface(self):\n+        from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient\n+\n+        client = AntigravityCloudCodeClient(api_key=\"dummy\")\n+        try:\n+            assert hasattr(client, \"chat\")\n+            assert hasattr(client.chat, \"completions\")\n+            assert callable(client.chat.completions.create)\n+        finally:\n+            client.close()\n+\n+    def test_create_uses_antigravity_endpoint_and_headers(self, monkeypatch):\n+        from agent import antigravity_oauth\n+        from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient\n+        from agent.antigravity_code_assist import ANTIGRAVITY_CODE_ASSIST_ENDPOINT\n+\n+        monkeypatch.setattr(antigravity_oauth, \"get_valid_access_token\", lambda: \"live-token\")\n+\n+        class _Response:\n+            status_code = 200\n+\n+            def json(self):\n+                return {\n+                    \"response\": {\n+                        \"candidates\": [{\n+                            \"content\": {\"parts\": [{\"text\": \"ok\"}]},\n+                            \"finishReason\": \"STOP\",\n+                        }]\n+                    }\n+                }\n+\n+        class _Http:\n+            def __init__(self):\n+                self.calls = []\n+\n+            def post(self, url, json=None, headers=None):\n+                self.calls.append((url, json, headers))\n+                return _Response()\n+\n+            def close(self):\n+                pass\n+\n+        client = AntigravityCloudCodeClient(project_id=\"proj-123\")\n+        client._http = _Http()\n+        try:\n+            result = client.chat.completions.create(\n+                model=\"gemini-3-flash-agent\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+        finally:\n+            client.close()\n+\n+        assert result.choices[0].message.content == \"ok\"\n+        url, body, headers = client._http.calls[0]\n+        assert url == f\"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent\"\n+        assert body[\"project\"] == \"proj-123\"\n+        assert body[\"model\"] == \"gemini-3-flash-agent\"\n+        assert headers[\"Authorization\"] == \"Bearer live-token\"\n+        assert json.loads(headers[\"Client-Metadata\"])[\"ideType\"] == \"ANTIGRAVITY\"\n+\n+\n+class TestAntigravityRegistration:\n+    def test_registry_entry_and_aliases(self):\n+        from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider\n+\n+        assert \"google-antigravity\" in PROVIDER_REGISTRY\n+        assert PROVIDER_REGISTRY[\"google-antigravity\"].auth_type == \"oauth_external\"\n+        assert resolve_provider(\"antigravity\") == \"google-antigravity\"\n+        assert resolve_provider(\"antigravity-oauth\") == \"google-antigravity\"\n+        assert resolve_provider(\"google-antigravity-oauth\") == \"google-antigravity\"\n+        assert resolve_provider(\"agy\") == \"google-antigravity\"\n+\n+    def test_runtime_provider_raises_when_not_logged_in(self):\n+        from hermes_cli.auth import AuthError\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+\n+        with pytest.raises(AuthError) as exc_info:\n+            resolve_runtime_provider(requested=\"google-antigravity\")\n+        assert exc_info.value.code == \"antigravity_oauth_not_logged_in\"\n+\n+    def test_runtime_provider_returns_correct_shape_when_logged_in(self):\n+        from agent.antigravity_oauth import AntigravityCredentials, save_credentials\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+\n+        save_credentials(AntigravityCredentials(\n+            access_token=\"[redacted:secret-assignment]\",\n+            refresh_token=\"rt\",\n+            expires_ms=int((time.time() + 3600) * 1000),\n+            project_id=\"my-proj\",\n+            email=\"[redacted:email]\",\n+        ))\n+\n+        result = resolve_runtime_provider(requested=\"google-antigravity\")\n+        assert result[\"provider\"] == \"google-antigravity\"\n+        assert result[\"api_mode\"] == \"chat_completions\"\n+        assert result[\"api_key\"] == \"live-tok\"\n+        assert result[\"base_url\"] == \"antigravity-pa://google\"\n+        assert result[\"project_id\"] == \"my-proj\"\n+        assert result[\"email\"] == \"[redacted:email]\"\n+\n+    def test_provider_model_ids_uses_live_antigravity_catalog(self, monkeypatch):\n+        from hermes_cli import models\n+\n+        monkeypatch.setattr(\n+            models,\n+            \"_fetch_antigravity_models\",\n+            lambda force_refresh=False: [\"gemini-3-flash-agent\", \"claude-sonnet-4-6\"],\n+        )\n+\n+        assert models.provider_model_ids(\"agy\") == [\n+            \"gemini-3-flash-agent\",\n+            \"claude-sonnet-4-6\",\n+        ]\n+\n+    def test_oauth_capable_set_includes_antigravity(self):\n+        from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS\n+\n+        assert \"google-antigravity\" in _OAUTH_CAPABLE_PROVIDERS"]},{"file":"tests/agent/test_gemini_cloudcode.py","hunks":["@@ -610,6 +610,7 @@ def test_tool_call_translation(self):\n         fc_part = next(p for p in model_turn[\"parts\"] if \"functionCall\" in p)\n         assert fc_part[\"functionCall\"][\"name\"] == \"get_weather\"\n         assert fc_part[\"functionCall\"][\"args\"] == {\"city\": \"SF\"}\n+        assert fc_part[\"functionCall\"][\"id\"] == \"call_1\"\n \n     def test_tool_result_translation(self):\n         from agent.gemini_cloudcode_adapter import build_gemini_request","@@ -632,6 +633,7 @@ def test_tool_result_translation(self):\n         fr_part = next(p for p in last[\"parts\"] if \"functionResponse\" in p)\n         assert fr_part[\"functionResponse\"][\"name\"] == \"get_weather\"\n         assert fr_part[\"functionResponse\"][\"response\"] == {\"temp\": 72}\n+        assert fr_part[\"functionResponse\"][\"id\"] == \"c1\"\n \n     def test_tools_translated_to_function_declarations(self):\n         from agent.gemini_cloudcode_adapter import build_gemini_request","@@ -790,7 +792,7 @@ def test_function_call_response(self):\n             \"response\": {\n                 \"candidates\": [{\n                     \"content\": {\"parts\": [{\n-                        \"functionCall\": {\"name\": \"lookup\", \"args\": {\"q\": \"weather\"}},\n+                        \"functionCall\": {\"name\": \"lookup\", \"args\": {\"q\": \"weather\"}, \"id\": \"provider-call-1\"},\n                     }]},\n                     \"finishReason\": \"STOP\",\n                 }],","@@ -798,6 +800,7 @@ def test_function_call_response(self):\n         }\n         result = _translate_gemini_response(resp, model=\"gemini-2.5-flash\")\n         tc = result.choices[0].message.tool_calls[0]\n+        assert tc.id == \"provider-call-1\"\n         assert tc.function.name == \"lookup\"\n         assert json.loads(tc.function.arguments) == {\"q\": \"weather\"}\n         assert result.choices[0].finish_reason == \"tool_calls\""]},{"file":"tests/agent/transports/test_chat_completions.py","hunks":["@@ -418,6 +418,20 @@ def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport):\n         }\n         assert \"google\" not in kw[\"extra_body\"]\n \n+    def test_google_antigravity_keeps_top_level_thinking_config(self, transport):\n+        msgs = [{\"role\": \"user\", \"content\": \"Hi\"}]\n+        kw = transport.build_kwargs(\n+            model=\"gemini-3-flash-agent\",\n+            messages=msgs,\n+            provider_name=\"google-antigravity\",\n+            reasoning_config={\"enabled\": True, \"effort\": \"high\"},\n+        )\n+        assert kw[\"extra_body\"][\"thinking_config\"] == {\n+            \"includeThoughts\": True,\n+            \"thinkingLevel\": \"high\",\n+        }\n+        assert \"google\" not in kw[\"extra_body\"]\n+\n     def test_gemini_flash_minimal_clamps_to_low(self, transport):\n         # Gemini 3 Flash documents low/medium/high; \"minimal\" isn't accepted,\n         # so clamp it down to \"low\" rather than forwarding it verbatim."]},{"file":"tests/hermes_cli/test_model_provider_persistence.py","hunks":["@@ -316,6 +316,41 @@ def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home,\n         assert model.get(\"default\") == \"minimax-m2.5\"\n         assert model.get(\"api_mode\") == \"anthropic_messages\"\n \n+    def test_antigravity_oauth_provider_saved_when_selected(self, config_home):\n+        \"\"\"_model_flow_google_antigravity should persist provider/base_url/model together.\"\"\"\n+        from hermes_cli.main import _model_flow_google_antigravity\n+        from hermes_cli.config import load_config\n+\n+        with patch(\n+            \"hermes_cli.auth.get_antigravity_oauth_auth_status\",\n+            return_value={\"logged_in\": True, \"email\": \"[redacted:email]\"},\n+        ), patch(\n+            \"hermes_cli.auth.resolve_antigravity_oauth_runtime_credentials\",\n+            return_value={\n+                \"provider\": \"google-antigravity\",\n+                \"api_key\": \"tok\",\n+                \"base_url\": \"antigravity-pa://google\",\n+                \"project_id\": \"proj-123\",\n+            },\n+        ), patch(\n+            \"hermes_cli.models.provider_model_ids\",\n+            return_value=[\"gemini-3-flash-agent\", \"claude-sonnet-4-6\"],\n+        ), patch(\n+            \"hermes_cli.auth._prompt_model_selection\",\n+            return_value=\"claude-sonnet-4-6\",\n+        ):\n+            _model_flow_google_antigravity(load_config(), \"old-model\")\n+\n+        import yaml\n+\n+        config = yaml.safe_load((config_home / \"config.yaml\").read_text()) or {}\n+        model = config.get(\"model\")\n+        assert isinstance(model, dict), f\"model should be dict, got {type(model)}\"\n+        assert model.get(\"provider\") == \"google-antigravity\"\n+        assert model.get(\"base_url\") == \"antigravity-pa://google\"\n+        assert model.get(\"default\") == \"claude-sonnet-4-6\"\n+        assert \"api_mode\" not in model\n+\n \n \n class TestBaseUrlValidation:"]},{"file":"website/docs/developer-guide/provider-runtime.md","hunks":["@@ -47,7 +47,7 @@ Current provider families include (see `plugins/model-providers/` for the comple\n - OpenAI Codex\n - Copilot / Copilot ACP\n - Anthropic (native)\n-- Google / Gemini (`gemini`, `google-gemini-cli`)\n+- Google / Gemini (`gemini`, `google-gemini-cli`, `google-antigravity`)\n - Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)\n - DeepSeek\n - Z.AI"]},{"file":"website/docs/guides/google-gemini.md","hunks":["@@ -111,6 +111,19 @@ hermes model\n \n 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.\n \n+Hermes also supports `google-antigravity` for Antigravity Code Assist:\n+\n+```bash\n+hermes model\n+# → Choose \"Google Antigravity (OAuth)\"\n+```\n+\n+That provider uses a separate Antigravity OAuth login and stores separate\n+credentials at `~/.hermes/auth/antigravity_oauth.json`. Its model picker uses\n+live Antigravity model discovery, so the list reflects the signed-in account's\n+subscription and can include Antigravity-only Gemini agent models plus other\n+entitled model families.\n+\n ## Available Models\n \n The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include:","@@ -193,6 +206,7 @@ The doctor checks:\n \n - Whether `GOOGLE_API_KEY` or `GEMINI_API_KEY` is available\n - Whether Gemini OAuth credentials exist for `google-gemini-cli`\n+- Whether Antigravity OAuth credentials exist for `google-antigravity`\n - Whether configured provider credentials can be resolved\n \n For OAuth quota usage, run this inside a Hermes session:"]},{"file":"website/docs/integrations/providers.md","hunks":["@@ -49,6 +49,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro\n | **Qwen OAuth** | `hermes model` → \"Qwen OAuth\" (provider: `qwen-oauth`; browser PKCE login) |\n | **MiniMax OAuth** | `hermes model` → \"MiniMax (OAuth)\" (provider: `minimax-oauth`; browser PKCE login) |\n | **StepFun** | `STEPFUN_API_KEY` in `~/.hermes/.env` (provider: `stepfun`) |\n+| **Google Antigravity (OAuth)** | `hermes model` → \"Google Antigravity (OAuth)\" (provider: `google-antigravity`, aliases: `antigravity`, `antigravity-oauth`, `agy`) |\n | **LM Studio** | `hermes model` → \"LM Studio\" (provider: `lmstudio`, optional `LM_API_KEY`) |\n | **Custom Endpoint** | `hermes model` → choose \"Custom endpoint\" (saved in `config.yaml`) |\n ","@@ -78,6 +79,64 @@ Don't have a subscription yet? Get one at [portal.nousresearch.com/manage-subscr\n **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.\n \n \n+### Google Antigravity via OAuth (`google-antigravity`)\n+\n+The `google-antigravity` provider uses Antigravity's Code Assist backend and\n+Antigravity OAuth scopes. It is a native Hermes integration: Hermes runs its\n+own browser PKCE login, stores credentials under\n+`~/.hermes/auth/antigravity_oauth.json`, and talks directly to the Antigravity\n+Code Assist endpoints. It does not shell out to `agy` for inference, and it\n+does not depend on the Antigravity CLI's local token storage.\n+\n+**Quick start:**\n+\n+```bash\n+hermes model\n+# -> pick \"Google Antigravity (OAuth)\"\n+# -> browser opens to accounts.google.com, sign in\n+# -> pick one of the models available to your Antigravity account\n+```\n+\n+Hermes discovers Antigravity models from `fetchAvailableModels` after login.\n+The visible list depends on the authenticated account and subscription, and can\n+include Antigravity-only Gemini agent models plus Claude and GPT-OSS entries\n+when the account is entitled. If live discovery fails, Hermes falls back to a\n+small curated list so the provider remains selectable.\n+\n+Supported aliases:\n+\n+```text\n+google-antigravity\n+google-antigravity-oauth\n+antigravity\n+antigravity-oauth\n+antigravity-cli\n+agy\n+agy-cli\n+```\n+\n+Optional overrides:\n+\n+```bash\n+HERMES_ANTIGRAVITY_CLIENT_ID=your-client.apps.googleusercontent.com\n+HERMES_ANTIGRAVITY_CLIENT_SECRET=...\n+HERMES_ANTIGRAVITY_CLI_PATH=/path/to/agy\n+HERMES_ANTIGRAVITY_PROJECT_ID=your-project\n+```\n+\n+If the client ID/secret are not set explicitly, Hermes tries to discover the\n+desktop OAuth client credentials from the installed Antigravity CLI (`agy`) on\n+`PATH`, `HERMES_ANTIGRAVITY_CLI_PATH`, or common Antigravity install/cache\n+locations. Those client credentials are used only to start and refresh Hermes'\n+own OAuth session; Hermes still keeps its access/refresh tokens in `~/.hermes`.\n+\n+:::note Windows credential storage\n+The Antigravity CLI may keep its own login in platform-specific storage such as\n+Windows Credential Manager. Hermes intentionally keeps separate credentials in\n+`~/.hermes` so development profiles and production Hermes profiles do not share\n+tokens accidentally.\n+:::\n+\n :::info Codex Note\n 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.\n ","@@ -1532,7 +1591,7 @@ fallback_model:\n \n 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.\n \n-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`.\n+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`.\n \n :::tip\n 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)."]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -100,7 +100,7 @@ Common options:\n | `-q`, `--query \"...\"` | One-shot, non-interactive prompt. |\n | `-m`, `--model <model>` | Override the model for this run. |\n | `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |\n-| `--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`). |\n+| `--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`). |\n | `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). |\n | `-v`, `--verbose` | Verbose output. |\n | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -70,6 +70,10 @@ Hermes reads environment variables from the process environment and, for user-ma\n | `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (optional; defaults to Google's public gemini-cli client) |\n | `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional) |\n | `HERMES_GEMINI_PROJECT_ID` | GCP project ID for paid Gemini tiers (free tier auto-provisions) |\n+| `HERMES_ANTIGRAVITY_CLIENT_ID` | OAuth client ID for `google-antigravity` PKCE login (optional; discovered from installed `agy` when omitted) |\n+| `HERMES_ANTIGRAVITY_CLIENT_SECRET` | OAuth client secret for `google-antigravity` (optional; discovered from installed `agy` when omitted) |\n+| `HERMES_ANTIGRAVITY_CLI_PATH` | Path to the `agy` executable or install file used for Antigravity OAuth client credential discovery |\n+| `HERMES_ANTIGRAVITY_PROJECT_ID` | GCP project ID for Antigravity Code Assist when you want to pin one explicitly |\n | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |\n | `ANTHROPIC_BASE_URL` | Override the Anthropic API base URL |\n | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |"]},{"file":"website/docs/reference/faq.md","hunks":["@@ -20,7 +20,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include:\n - **[Nous Portal](/integrations/nous-portal)** — Nous Research's subscription gateway — 300+ models plus web/image/TTS/browser through one OAuth login (recommended for newcomers)\n - **OpenAI** — GPT-5.4, GPT-5-codex, GPT-4.1, GPT-4o, etc.\n - **Anthropic** — Claude models (direct API, OAuth via `hermes auth add anthropic`, OpenRouter, or any compatible proxy)\n-- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, OpenRouter, or compatible proxy)\n+- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy)\n - **z.ai / ZhipuAI** — GLM models\n - **Kimi / Moonshot AI** — Kimi models\n - **MiniMax** — global and China endpoints"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -959,7 +959,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t\n \n 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.\n \n-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\"`).\n+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\"`).\n \n :::tip MiniMax OAuth\n `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)."]},{"file":"website/docs/user-guide/features/fallback-providers.md","hunks":["@@ -63,6 +63,7 @@ Each entry requires both `provider` and `model`. Entries missing either field ar\n | StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) |\n | Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |\n | Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) |\n+| Google Antigravity (OAuth) | `google-antigravity` | `hermes model` (Antigravity OAuth; optional: `HERMES_ANTIGRAVITY_PROJECT_ID`) |\n | Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) |\n | xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) |\n | xAI Grok OAuth (SuperGrok) | `xai-oauth` (alias `grok-oauth`) | `hermes model` → xAI Grok OAuth (browser login; SuperGrok subscription) |\n"]}]},{"kind":"pr","number":54924,"title":"docs(google-gemini): explain google-gemini-cli removal and migration to gemini","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}