{"repo":"NousResearch/hermes-agent","public_id":"15895","display_label":"Gemini 429 error handling and provider cleanup","summary":"These items address distinguishing transient Gemini rate-limit errors (per-minute TPM/RPM) from daily quota exhaustion, improving error messaging accordingly. Also included is removal of the google-gemini-cli and google-antigravity OAuth providers.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50492},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-22T02:53:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":38804,"pr_number":50492,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":15895,"title":"[Bug]: google-gemini-cli causing 429 but gquota ok","url":null,"state":"closed"},{"kind":"issue","number":38804,"title":"[Bug]: Temporary Gemini rate limits are surfaced as \"quota exhausted\" errors","url":null,"state":"closed"},{"kind":"pr","number":30416,"title":"fix(provider/gemini): clarify RESOURCE_EXHAUSTED 429 error — distinguish free-tier routing from per-minute limits","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":39077,"title":"fix(agent): distinguish transient Gemini rate limits from daily quota exhaustion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7130d6086","number":0},{"kind":"pr","value":"#50492","number":50492},{"kind":"pr","value":"#38804","number":38804}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40755,"title":"fix(agent): add per-minute TPM/RPM cooldown tracker for Gemini 429 handling","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7130d60861a9243301514bff611a9381830d59d8","number":0},{"kind":"pr","value":"#15895","number":15895}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50492,"title":"feat(providers): remove google-gemini-cli + google-antigravity OAuth providers","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/google_code_assist.py","agent/google_oauth.py","agent/transports/chat_completions.py","apps/desktop/src/app/settings/constants.ts","apps/desktop/src/app/settings/helpers.test.ts","apps/desktop/src/lib/desktop-slash-commands.ts","cli.py","hermes_cli/auth.py","hermes_cli/auth_commands.py","hermes_cli/cli_commands_mixin.py","hermes_cli/commands.py","hermes_cli/config.py","hermes_cli/doctor.py","hermes_cli/main.py","hermes_cli/model_setup_flows.py","hermes_cli/models.py","hermes_cli/provider_catalog.py","hermes_cli/providers.py","hermes_cli/runtime_provider.py","hermes_cli/tips.py","hermes_cli/web_server.py","plans/gemini-oauth-provider.md","plugins/model-providers/gemini/__init__.py","run_agent.py","skills/autonomous-ai-agents/hermes-agent/SKILL.md","tests/agent/test_antigravity_cloudcode.py","tests/agent/test_gemini_cloudcode.py","tests/agent/test_gemini_fast_fallback.py","tests/agent/transports/test_chat_completions.py","tests/agent/transports/test_codex_app_server_runtime.py","tests/cli/test_gquota_command.py","tests/hermes_cli/test_auth_commands.py","tests/hermes_cli/test_config.py","tests/hermes_cli/test_doctor.py","tests/hermes_cli/test_model_provider_persistence.py","tests/hermes_cli/test_provider_catalog.py","tests/hermes_cli/test_web_oauth_dispatch.py","tests/skills/test_google_oauth_setup.py","website/docs/developer-guide/adding-providers.md","website/docs/developer-guide/model-provider-plugin.md","website/docs/developer-guide/provider-runtime.md","website/docs/getting-started/quickstart.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/reference/slash-commands.md","website/docs/user-guide/configuration.md","website/docs/user-guide/features/fallback-providers.md","website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1378,37 +1378,6 @@ 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-gemini-cli\" or str(client_kwargs.get(\"base_url\", \"\")).startswith(\"cloudcode-pa://\"):\n-        from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient\n-\n-        # Strip OpenAI-specific kwargs the Gemini client doesn't accept\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 = GeminiCloudCodeClient(**safe_kwargs)\n-        _ra().logger.info(\n-            \"Gemini Cloud 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 == \"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":"/dev/null","hunks":["@@ -1,164 +0,0 @@\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":"/dev/null","hunks":["@@ -1,286 +0,0 @@\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":"/dev/null","hunks":["@@ -1,907 +0,0 @@\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":"/dev/null","hunks":["@@ -1,915 +0,0 @@\n-\"\"\"OpenAI-compatible facade that talks to Google's Cloud Code Assist backend.\n-\n-This adapter lets Hermes use the ``google-gemini-cli`` provider as if it were\n-a standard OpenAI-shaped chat completion endpoint, while the underlying HTTP\n-traffic goes to ``cloudcode-pa.googleapis.com/v1internal:{generateContent,\n-streamGenerateContent}`` with a Bearer access token obtained via OAuth PKCE.\n-\n-Architecture\n-------------\n-- ``GeminiCloudCodeClient`` exposes ``.chat.completions.create(**kwargs)``\n-  mirroring the subset of the OpenAI SDK that ``run_agent.py`` uses.\n-- Incoming OpenAI ``messages[]`` / ``tools[]`` / ``tool_choice`` are translated\n-  to Gemini's native ``contents[]`` / ``tools[].functionDeclarations`` /\n-  ``toolConfig`` / ``systemInstruction`` shape.\n-- The request body is wrapped ``{project, model, user_prompt_id, request}``\n-  per Code Assist API expectations.\n-- Responses (``candidates[].content.parts[]``) are converted back to\n-  OpenAI ``choices[0].message`` shape with ``content`` + ``tool_calls``.\n-- Streaming uses SSE (``?alt=sse``) and yields OpenAI-shaped delta chunks.\n-\n-Attribution\n------------\n-Translation semantics follow jenslys/opencode-gemini-auth (MIT) and the public\n-Gemini API docs. Request envelope shape\n-(``{project, model, user_prompt_id, request}``) is documented nowhere; it is\n-reverse-engineered from the opencode-gemini-auth and clawdbot implementations.\n-\"\"\"\n-\n-from __future__ import annotations\n-\n-import json\n-import logging\n-import time\n-import uuid\n-from types import SimpleNamespace\n-from typing import Any, Dict, Iterator, List, Optional\n-\n-import httpx\n-\n-from agent import google_oauth\n-from agent.gemini_schema import sanitize_gemini_tool_parameters\n-from agent.google_code_assist import (\n-    CODE_ASSIST_ENDPOINT,\n-    CodeAssistError,\n-    ProjectContext,\n-    resolve_project_context,\n-)\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-# =============================================================================\n-# Request translation: OpenAI → Gemini\n-# =============================================================================\n-\n-_ROLE_MAP_OPENAI_TO_GEMINI = {\n-    \"user\": \"user\",\n-    \"assistant\": \"model\",\n-    \"system\": \"user\",   # handled separately via systemInstruction\n-    \"tool\": \"user\",     # functionResponse is wrapped in a user-role turn\n-    \"function\": \"user\",\n-}\n-\n-\n-def _coerce_content_to_text(content: Any) -> str:\n-    \"\"\"OpenAI content may be str or a list of parts; reduce to plain text.\"\"\"\n-    if content is None:\n-        return \"\"\n-    if isinstance(content, str):\n-        return content\n-    if isinstance(content, list):\n-        pieces: List[str] = []\n-        for p in content:\n-            if isinstance(p, str):\n-                pieces.append(p)\n-            elif isinstance(p, dict):\n-                if p.get(\"type\") == \"text\" and isinstance(p.get(\"text\"), str):\n-                    pieces.append(p[\"text\"])\n-                # Multimodal (image_url, etc.) — stub for now; log and skip\n-                elif p.get(\"type\") in {\"image_url\", \"input_audio\"}:\n-                    logger.debug(\"Dropping multimodal part (not yet supported): %s\", p.get(\"type\"))\n-        return \"\\n\".join(pieces)\n-    return str(content)\n-\n-\n-def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:\n-    \"\"\"OpenAI tool_call -> Gemini functionCall part.\"\"\"\n-    fn = tool_call.get(\"function\") or {}\n-    args_raw = fn.get(\"arguments\", \"\")\n-    try:\n-        args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}\n-    except json.JSONDecodeError:\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\": 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.\n-        \"thoughtSignature\": \"skip_thought_signature_validator\",\n-    }\n-\n-\n-def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:\n-    \"\"\"OpenAI tool-role message -> Gemini functionResponse part.\n-\n-    The function name isn't in the OpenAI tool message directly; it must be\n-    passed via the assistant message that issued the call. For simplicity we\n-    look up ``name`` on the message (OpenAI SDK copies it there) or on the\n-    ``tool_call_id`` cross-reference.\n-    \"\"\"\n-    name = str(message.get(\"name\") or message.get(\"tool_call_id\") or \"tool\")\n-    content = _coerce_content_to_text(message.get(\"content\"))\n-    # Gemini expects the response as a dict under `response`. We wrap plain\n-    # text in {\"output\": \"...\"}.\n-    try:\n-        parsed = json.loads(content) if content.strip().startswith((\"{\", \"[\")) else None\n-    except json.JSONDecodeError:\n-        parsed = None\n-    response = parsed if isinstance(parsed, dict) else {\"output\": content}\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(\n-    messages: List[Dict[str, Any]],\n-) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:\n-    \"\"\"Convert OpenAI messages[] to Gemini contents[] + systemInstruction.\"\"\"\n-    system_text_parts: List[str] = []\n-    contents: List[Dict[str, Any]] = []\n-\n-    for msg in messages:\n-        if not isinstance(msg, dict):\n-            continue\n-        role = str(msg.get(\"role\") or \"user\")\n-\n-        if role == \"system\":\n-            system_text_parts.append(_coerce_content_to_text(msg.get(\"content\")))\n-            continue\n-\n-        # Tool result message — emit a user-role turn with functionResponse\n-        if role == \"tool\" or role == \"function\":\n-            contents.append({\n-                \"role\": \"user\",\n-                \"parts\": [_translate_tool_result_to_gemini(msg)],\n-            })\n-            continue\n-\n-        gemini_role = _ROLE_MAP_OPENAI_TO_GEMINI.get(role, \"user\")\n-        parts: List[Dict[str, Any]] = []\n-\n-        text = _coerce_content_to_text(msg.get(\"content\"))\n-        if text:\n-            parts.append({\"text\": text})\n-\n-        # Assistant messages can carry tool_calls\n-        tool_calls = msg.get(\"tool_calls\") or []\n-        if isinstance(tool_calls, list):\n-            for tc in tool_calls:\n-                if isinstance(tc, dict):\n-                    parts.append(_translate_tool_call_to_gemini(tc))\n-\n-        if not parts:\n-            # Gemini rejects empty parts; skip the turn entirely\n-            continue\n-\n-        contents.append({\"role\": gemini_role, \"parts\": parts})\n-\n-    system_instruction: Optional[Dict[str, Any]] = None\n-    joined_system = \"\\n\".join(p for p in system_text_parts if p).strip()\n-    if joined_system:\n-        system_instruction = {\n-            \"role\": \"system\",\n-            \"parts\": [{\"text\": joined_system}],\n-        }\n-\n-    return contents, system_instruction\n-\n-\n-def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:\n-    \"\"\"OpenAI tools[] -> Gemini tools[].functionDeclarations[].\"\"\"\n-    if not isinstance(tools, list) or not tools:\n-        return []\n-    declarations: List[Dict[str, Any]] = []\n-    for t in tools:\n-        if not isinstance(t, dict):\n-            continue\n-        fn = t.get(\"function\") or {}\n-        if not isinstance(fn, dict):\n-            continue\n-        name = fn.get(\"name\")\n-        if not name:\n-            continue\n-        decl = {\"name\": str(name)}\n-        if fn.get(\"description\"):\n-            decl[\"description\"] = str(fn[\"description\"])\n-        params = fn.get(\"parameters\")\n-        if isinstance(params, dict):\n-            decl[\"parameters\"] = sanitize_gemini_tool_parameters(params)\n-        declarations.append(decl)\n-    if not declarations:\n-        return []\n-    return [{\"functionDeclarations\": declarations}]\n-\n-\n-def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:\n-    \"\"\"OpenAI tool_choice -> Gemini toolConfig.functionCallingConfig.\"\"\"\n-    if tool_choice is None:\n-        return None\n-    if isinstance(tool_choice, str):\n-        if tool_choice == \"auto\":\n-            return {\"functionCallingConfig\": {\"mode\": \"AUTO\"}}\n-        if tool_choice == \"required\":\n-            return {\"functionCallingConfig\": {\"mode\": \"ANY\"}}\n-        if tool_choice == \"none\":\n-            return {\"functionCallingConfig\": {\"mode\": \"NONE\"}}\n-    if isinstance(tool_choice, dict):\n-        fn = tool_choice.get(\"function\") or {}\n-        name = fn.get(\"name\")\n-        if name:\n-            return {\n-                \"functionCallingConfig\": {\n-                    \"mode\": \"ANY\",\n-                    \"allowedFunctionNames\": [str(name)],\n-                },\n-            }\n-    return None\n-\n-\n-def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:\n-    \"\"\"Accept thinkingBudget / thinkingLevel / includeThoughts (+ snake_case).\"\"\"\n-    if not isinstance(config, dict) or not config:\n-        return None\n-    budget = config.get(\"thinkingBudget\", config.get(\"thinking_budget\"))\n-    level = config.get(\"thinkingLevel\", config.get(\"thinking_level\"))\n-    include = config.get(\"includeThoughts\", config.get(\"include_thoughts\"))\n-    normalized: Dict[str, Any] = {}\n-    if isinstance(budget, (int, float)):\n-        normalized[\"thinkingBudget\"] = int(budget)\n-    if isinstance(level, str) and level.strip():\n-        normalized[\"thinkingLevel\"] = level.strip().lower()\n-    if isinstance(include, bool):\n-        normalized[\"includeThoughts\"] = include\n-    return normalized or None\n-\n-\n-def build_gemini_request(\n-    *,\n-    messages: List[Dict[str, Any]],\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-    thinking_config: Any = None,\n-) -> Dict[str, Any]:\n-    \"\"\"Build the inner Gemini request body (goes inside ``request`` wrapper).\"\"\"\n-    contents, system_instruction = _build_gemini_contents(messages)\n-\n-    body: Dict[str, Any] = {\"contents\": contents}\n-    if system_instruction is not None:\n-        body[\"systemInstruction\"] = system_instruction\n-\n-    gemini_tools = _translate_tools_to_gemini(tools)\n-    if gemini_tools:\n-        body[\"tools\"] = gemini_tools\n-    tool_cfg = _translate_tool_choice_to_gemini(tool_choice)\n-    if tool_cfg is not None:\n-        body[\"toolConfig\"] = tool_cfg\n-\n-    generation_config: Dict[str, Any] = {}\n-    if isinstance(temperature, (int, float)):\n-        generation_config[\"temperature\"] = float(temperature)\n-    if isinstance(max_tokens, int) and max_tokens > 0:\n-        generation_config[\"maxOutputTokens\"] = max_tokens\n-    if isinstance(top_p, (int, float)):\n-        generation_config[\"topP\"] = float(top_p)\n-    if isinstance(stop, str) and stop:\n-        generation_config[\"stopSequences\"] = [stop]\n-    elif isinstance(stop, list) and stop:\n-        generation_config[\"stopSequences\"] = [str(s) for s in stop if s]\n-    normalized_thinking = _normalize_thinking_config(thinking_config)\n-    if normalized_thinking:\n-        generation_config[\"thinkingConfig\"] = normalized_thinking\n-    if generation_config:\n-        body[\"generationConfig\"] = generation_config\n-\n-    return body\n-\n-\n-def wrap_code_assist_request(\n-    *,\n-    project_id: str,\n-    model: str,\n-    inner_request: Dict[str, Any],\n-    user_prompt_id: Optional[str] = None,\n-) -> Dict[str, Any]:\n-    \"\"\"Wrap the inner Gemini request in the Code Assist envelope.\"\"\"\n-    return {\n-        \"project\": project_id,\n-        \"model\": model,\n-        \"user_prompt_id\": user_prompt_id or str(uuid.uuid4()),\n-        \"request\": inner_request,\n-    }\n-\n-\n-# =============================================================================\n-# Response translation: Gemini → OpenAI\n-# =============================================================================\n-\n-def _translate_gemini_response(\n-    resp: Dict[str, Any],\n-    model: str,\n-) -> SimpleNamespace:\n-    \"\"\"Non-streaming Gemini response -> OpenAI-shaped SimpleNamespace.\n-\n-    Code Assist wraps the actual Gemini response inside ``response``, so we\n-    unwrap it first if present.\n-    \"\"\"\n-    inner = resp.get(\"response\") if isinstance(resp.get(\"response\"), dict) else resp\n-\n-    candidates = inner.get(\"candidates\") or []\n-    if not isinstance(candidates, list) or not candidates:\n-        return _empty_response(model)\n-\n-    cand = candidates[0]\n-    content_obj = cand.get(\"content\") if isinstance(cand, dict) else {}\n-    parts = content_obj.get(\"parts\") if isinstance(content_obj, dict) else []\n-\n-    text_pieces: List[str] = []\n-    reasoning_pieces: List[str] = []\n-    tool_calls: List[SimpleNamespace] = []\n-\n-    for i, part in enumerate(parts or []):\n-        if not isinstance(part, dict):\n-            continue\n-        # Thought parts are model's internal reasoning — surface as reasoning,\n-        # don't mix into content.\n-        if part.get(\"thought\") is True:\n-            if isinstance(part.get(\"text\"), str):\n-                reasoning_pieces.append(part[\"text\"])\n-            continue\n-        if isinstance(part.get(\"text\"), str):\n-            text_pieces.append(part[\"text\"])\n-            continue\n-        fc = part.get(\"functionCall\")\n-        if isinstance(fc, dict) and fc.get(\"name\"):\n-            try:\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=call_id,\n-                type=\"function\",\n-                index=i,\n-                function=SimpleNamespace(name=str(fc[\"name\"]), arguments=args_str),\n-            ))\n-\n-    finish_reason = \"tool_calls\" if tool_calls else _map_gemini_finish_reason(\n-        str(cand.get(\"finishReason\") or \"\")\n-    )\n-\n-    usage_meta = inner.get(\"usageMetadata\") or {}\n-    usage = SimpleNamespace(\n-        prompt_tokens=[redacted:secret-assignment]\"promptTokenCount\") or 0),\n-        completion_tokens=[redacted:secret-assignment]\"candidatesTokenCount\") or 0),\n-        total_tokens=[redacted:secret-assignment]\"totalTokenCount\") or 0),\n-        prompt_tokens_details=[redacted:secret-assignment]\n-            cached_tokens=[redacted:secret-assignment]\"cachedContentTokenCount\") or 0),\n-        ),\n-    )\n-\n-    message = SimpleNamespace(\n-        role=\"assistant\",\n-        content=\"\".join(text_pieces) if text_pieces else None,\n-        tool_calls=tool_calls or None,\n-        reasoning=\"\".join(reasoning_pieces) or None,\n-        reasoning_content=\"\".join(reasoning_pieces) or None,\n-        reasoning_details=None,\n-    )\n-    choice = SimpleNamespace(\n-        index=0,\n-        message=message,\n-        finish_reason=finish_reason,\n-    )\n-    return SimpleNamespace(\n-        id=f\"chatcmpl-{uuid.uuid4().hex[:12]}\",\n-        object=\"chat.completion\",\n-        created=int(time.time()),\n-        model=model,\n-        choices=[choice],\n-        usage=usage,\n-    )\n-\n-\n-def _empty_response(model: str) -> SimpleNamespace:\n-    message = SimpleNamespace(\n-        role=\"assistant\", content=\"\", tool_calls=None,\n-        reasoning=None, reasoning_content=None, reasoning_details=None,\n-    )\n-    choice = SimpleNamespace(index=0, message=message, finish_reason=\"stop\")\n-    usage = SimpleNamespace(\n-        prompt_tokens=0, completion_tokens=0, total_tokens=0,\n-        prompt_tokens_details=[redacted:secret-assignment]\n-    )\n-    return SimpleNamespace(\n-        id=f\"chatcmpl-{uuid.uuid4().hex[:12]}\",\n-        object=\"chat.completion\",\n-        created=int(time.time()),\n-        model=model,\n-        choices=[choice],\n-        usage=usage,\n-    )\n-\n-\n-def _map_gemini_finish_reason(reason: str) -> str:\n-    mapping = {\n-        \"STOP\": \"stop\",\n-        \"MAX_TOKENS\": \"length\",\n-        \"SAFETY\": \"content_filter\",\n-        \"RECITATION\": \"content_filter\",\n-        \"OTHER\": \"stop\",\n-    }\n-    return mapping.get(reason.upper(), \"stop\")\n-\n-\n-# =============================================================================\n-# Streaming SSE iterator\n-# =============================================================================\n-\n-class _GeminiStreamChunk(SimpleNamespace):\n-    \"\"\"Mimics an OpenAI ChatCompletionChunk with .choices[0].delta.\"\"\"\n-    pass\n-\n-\n-def _make_stream_chunk(\n-    *,\n-    model: str,\n-    content: str = \"\",\n-    tool_call_delta: Optional[Dict[str, Any]] = None,\n-    finish_reason: Optional[str] = None,\n-    reasoning: str = \"\",\n-) -> _GeminiStreamChunk:\n-    delta_kwargs: Dict[str, Any] = {\n-        \"role\": \"assistant\",\n-        \"content\": None,\n-        \"tool_calls\": None,\n-        \"reasoning\": None,\n-        \"reasoning_content\": None,\n-    }\n-    if content:\n-        delta_kwargs[\"content\"] = content\n-    if tool_call_delta is not None:\n-        delta_kwargs[\"tool_calls\"] = [SimpleNamespace(\n-            index=tool_call_delta.get(\"index\", 0),\n-            id=tool_call_delta.get(\"id\") or f\"call_{uuid.uuid4().hex[:12]}\",\n-            type=\"function\",\n-            function=SimpleNamespace(\n-                name=tool_call_delta.get(\"name\") or \"\",\n-                arguments=tool_call_delta.get(\"arguments\") or \"\",\n-            ),\n-        )]\n-    if reasoning:\n-        delta_kwargs[\"reasoning\"] = reasoning\n-        delta_kwargs[\"reasoning_content\"] = reasoning\n-    delta = SimpleNamespace(**delta_kwargs)\n-    choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)\n-    return _GeminiStreamChunk(\n-        id=f\"chatcmpl-{uuid.uuid4().hex[:12]}\",\n-        object=\"chat.completion.chunk\",\n-        created=int(time.time()),\n-        model=model,\n-        choices=[choice],\n-        usage=None,\n-    )\n-\n-\n-def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:\n-    \"\"\"Parse Server-Sent Events from an httpx streaming response.\"\"\"\n-    buffer = \"\"\n-    for chunk in response.iter_text():\n-        if not chunk:\n-            continue\n-        buffer += chunk\n-        while \"\\n\" in buffer:\n-            line, buffer = buffer.split(\"\\n\", 1)\n-            line = line.rstrip(\"\\r\")\n-            if not line:\n-                continue\n-            if line.startswith(\"data: \"):\n-                data = line[6:]\n-                if data == \"[DONE]\":\n-                    return\n-                try:\n-                    yield json.loads(data)\n-                except json.JSONDecodeError:\n-                    logger.debug(\"Non-JSON SSE line: %s\", data[:200])\n-\n-\n-def _translate_stream_event(\n-    event: Dict[str, Any],\n-    model: str,\n-    tool_call_counter: List[int],\n-) -> List[_GeminiStreamChunk]:\n-    \"\"\"Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).\n-\n-    ``tool_call_counter`` is a single-element list used as a mutable counter\n-    across events in the same stream. Each ``functionCall`` part gets a\n-    fresh, unique OpenAI ``index`` — keying by function name would collide\n-    whenever the model issues parallel calls to the same tool (e.g. reading\n-    three files in one turn).\n-    \"\"\"\n-    inner = event.get(\"response\") if isinstance(event.get(\"response\"), dict) else event\n-    candidates = inner.get(\"candidates\") or []\n-    if not candidates:\n-        return []\n-    cand = candidates[0]\n-    if not isinstance(cand, dict):\n-        return []\n-\n-    chunks: List[_GeminiStreamChunk] = []\n-\n-    content = cand.get(\"content\") or {}\n-    parts = content.get(\"parts\") if isinstance(content, dict) else []\n-    for part in parts or []:\n-        if not isinstance(part, dict):\n-            continue\n-        if part.get(\"thought\") is True and isinstance(part.get(\"text\"), str):\n-            chunks.append(_make_stream_chunk(\n-                model=model, reasoning=part[\"text\"],\n-            ))\n-            continue\n-        if isinstance(part.get(\"text\"), str) and part[\"text\"]:\n-            chunks.append(_make_stream_chunk(model=model, content=part[\"text\"]))\n-        fc = part.get(\"functionCall\")\n-        if isinstance(fc, dict) and fc.get(\"name\"):\n-            name = str(fc[\"name\"])\n-            idx = tool_call_counter[0]\n-            tool_call_counter[0] += 1\n-            try:\n-                args_str = json.dumps(fc.get(\"args\") or {}, ensure_ascii=False)\n-            except (TypeError, ValueError):\n-                args_str = \"{}\"\n-            chunks.append(_make_stream_chunk(\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-                },\n-            ))\n-\n-    finish_reason_raw = str(cand.get(\"finishReason\") or \"\")\n-    if finish_reason_raw:\n-        mapped = _map_gemini_finish_reason(finish_reason_raw)\n-        if tool_call_counter[0] > 0:\n-            mapped = \"tool_calls\"\n-        chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))\n-    return chunks\n-\n-\n-# =============================================================================\n-# GeminiCloudCodeClient — OpenAI-compatible facade\n-# =============================================================================\n-\n-MARKER_BASE_URL = \"cloudcode-pa://google\"\n-\n-\n-class _GeminiChatCompletions:\n-    def __init__(self, client: \"GeminiCloudCodeClient\"):\n-        self._client = client\n-\n-    def create(self, **kwargs: Any) -> Any:\n-        return self._client._create_chat_completion(**kwargs)\n-\n-\n-class _GeminiChatNamespace:\n-    def __init__(self, client: \"GeminiCloudCodeClient\"):\n-        self.completions = _GeminiChatCompletions(client)\n-\n-\n-class GeminiCloudCodeClient:\n-    \"\"\"Minimal OpenAI-SDK-compatible facade over Code Assist v1internal.\"\"\"\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-        **_: Any,\n-    ):\n-        # `api_key` here is a dummy — real auth is the OAuth access token\n-        # fetched on every call via agent.google_oauth.get_valid_access_token().\n-        # We accept the kwarg for openai.OpenAI interface parity.\n-        self.api_key = [redacted:secret-assignment] or \"google-oauth\"\n-        self.base_url = base_url or MARKER_BASE_URL\n-        self._default_headers = dict(default_headers or {})\n-        self._configured_project_id = project_id\n-        self._project_context: Optional[ProjectContext] = None\n-        self._project_context_lock = False  # simple single-thread guard\n-        self.chat = _GeminiChatNamespace(self)\n-        self.is_closed = False\n-        self._http = httpx.Client(timeout=httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))\n-\n-    def close(self) -> None:\n-        self.is_closed = True\n-        try:\n-            self._http.close()\n-        except Exception:\n-            pass\n-\n-    # Implement the OpenAI SDK's context-manager-ish closure check\n-    def __enter__(self):\n-        return self\n-\n-    def __exit__(self, exc_type, exc_val, exc_tb):\n-        self.close()\n-\n-    def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:\n-        \"\"\"Lazily resolve and cache the project context for this client.\"\"\"\n-        if self._project_context is not None:\n-            return self._project_context\n-\n-        env_project = google_oauth.resolve_project_id_from_env()\n-        creds = google_oauth.load_credentials()\n-        stored_project = creds.project_id if creds else \"\"\n-\n-        # Prefer what's already baked into the creds\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-                tier_id=\"\",\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-            user_agent_model=model,\n-        )\n-        # Persist discovered project back to the creds file so the next\n-        # session doesn't re-run the discovery.\n-        if ctx.project_id or ctx.managed_project_id:\n-            google_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-2.5-flash\",\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 = {\n-            \"Content-Type\": \"application/json\",\n-            \"Accept\": \"application/json\",\n-            \"Authorization\": f\"Bearer {access_token}\",\n-            \"User-Agent\": \"hermes-agent (gemini-cli-compat)\",\n-            \"X-Goog-Api-Client\": \"gl-python/hermes\",\n-            \"x-activity-request-id\": str(uuid.uuid4()),\n-        }\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\"{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 Code Assist: {exc}\",\n-                code=\"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-        \"\"\"Generator that yields OpenAI-shaped streaming chunks.\"\"\"\n-        url = f\"{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-                        # Materialize error body for better diagnostics\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\"Streaming request failed: {exc}\",\n-                    code=\"code_assist_stream_error\",\n-                ) from exc\n-\n-        return _generator()\n-\n-\n-def _gemini_http_error(response: httpx.Response) -> CodeAssistError:\n-    \"\"\"Translate an httpx response into a CodeAssistError with rich metadata.\n-\n-    Parses Google's error envelope (``{\"error\": {\"code\", \"message\", \"status\",\n-    \"details\": [...]}}``) so the agent's error classifier can reason about\n-    the failure — ``status_code`` enables the rate_limit / auth classification\n-    paths, and ``response`` lets the main loop honor ``Retry-After`` just\n-    like it does for OpenAI SDK exceptions.\n-\n-    Also lifts a few recognizable Google conditions into human-readable\n-    messages so the user sees something better than a 500-char JSON dump:\n-\n-        MODEL_CAPACITY_EXHAUSTED → \"Gemini model capacity exhausted for\n-            <model>. This is a Google-side throttle...\"\n-        RESOURCE_EXHAUSTED w/o reason → quota-style message\n-        404 → \"Model <name> not found at cloudcode-pa...\"\n-    \"\"\"\n-    status = response.status_code\n-\n-    # Parse the body once, surviving any weird encodings.\n-    body_text = \"\"\n-    body_json: Dict[str, Any] = {}\n-    try:\n-        body_text = response.text\n-    except Exception:\n-        body_text = \"\"\n-    if body_text:\n-        try:\n-            parsed = json.loads(body_text)\n-            if isinstance(parsed, dict):\n-                body_json = parsed\n-        except (ValueError, TypeError):\n-            body_json = {}\n-\n-    # Dig into Google's error envelope.  Shape is:\n-    #   {\"error\": {\"code\": 429, \"message\": \"...\", \"status\": \"RESOURCE_EXHAUSTED\",\n-    #              \"details\": [{\"@type\": \".../ErrorInfo\", \"reason\": \"MODEL_CAPACITY_EXHAUSTED\",\n-    #                           \"metadata\": {...}},\n-    #                          {\"@type\": \".../RetryInfo\", \"retryDelay\": \"30s\"}]}}\n-    err_obj = body_json.get(\"error\") if isinstance(body_json, dict) else None\n-    if not isinstance(err_obj, dict):\n-        err_obj = {}\n-    err_status = str(err_obj.get(\"status\") or \"\").strip()\n-    err_message = str(err_obj.get(\"message\") or \"\").strip()\n-    _raw_details = err_obj.get(\"details\")\n-    err_details_list = _raw_details if isinstance(_raw_details, list) else []\n-\n-    # Extract google.rpc.ErrorInfo reason + metadata.  There may be more\n-    # than one ErrorInfo (rare), so we pick the first one with a reason.\n-    error_reason = \"\"\n-    error_metadata: Dict[str, Any] = {}\n-    retry_delay_seconds: Optional[float] = None\n-    for detail in err_details_list:\n-        if not isinstance(detail, dict):\n-            continue\n-        type_url = str(detail.get(\"@type\") or \"\")\n-        if not error_reason and type_url.endswith(\"/google.rpc.ErrorInfo\"):\n-            reason = detail.get(\"reason\")\n-            if isinstance(reason, str) and reason:\n-                error_reason = reason\n-            md = detail.get(\"metadata\")\n-            if isinstance(md, dict):\n-                error_metadata = md\n-        elif retry_delay_seconds is None and type_url.endswith(\"/google.rpc.RetryInfo\"):\n-            # retryDelay is a google.protobuf.Duration string like \"30s\" or \"1.5s\".\n-            delay_raw = detail.get(\"retryDelay\")\n-            if isinstance(delay_raw, str) and delay_raw.endswith(\"s\"):\n-                try:\n-                    retry_delay_seconds = float(delay_raw[:-1])\n-                except ValueError:\n-                    pass\n-            elif isinstance(delay_raw, (int, float)):\n-                retry_delay_seconds = float(delay_raw)\n-\n-    # Fall back to the Retry-After header if the body didn't include RetryInfo.\n-    if retry_delay_seconds is None:\n-        try:\n-            header_val = response.headers.get(\"Retry-After\") or response.headers.get(\"retry-after\")\n-        except Exception:\n-            header_val = None\n-        if header_val:\n-            try:\n-                retry_delay_seconds = float(header_val)\n-            except (TypeError, ValueError):\n-                retry_delay_seconds = None\n-\n-    # Classify the error code.  ``code_assist_rate_limited`` stays the default\n-    # for 429s; a more specific reason tag helps downstream callers (e.g. tests,\n-    # logs) without changing the rate_limit classification path.\n-    code = f\"code_assist_http_{status}\"\n-    if status == 401:\n-        code = \"code_assist_unauthorized\"\n-    elif status == 429:\n-        code = \"code_assist_rate_limited\"\n-        if error_reason == \"MODEL_CAPACITY_EXHAUSTED\":\n-            code = \"code_assist_capacity_exhausted\"\n-\n-    # Build a human-readable message.  Keep the status + a raw-body tail for\n-    # debugging, but lead with a friendlier summary when we recognize the\n-    # Google signal.\n-    model_hint = \"\"\n-    if isinstance(error_metadata, dict):\n-        model_hint = str(error_metadata.get(\"model\") or error_metadata.get(\"modelId\") or \"\").strip()\n-\n-    if status == 429 and error_reason == \"MODEL_CAPACITY_EXHAUSTED\":\n-        target = model_hint or \"this Gemini model\"\n-        message = (\n-            f\"Gemini capacity exhausted for {target} (Google-side throttle, \"\n-            f\"not a Hermes issue). Try a different Gemini model or set a \"\n-            f\"fallback_providers entry to a non-Gemini provider.\"\n-        )\n-        if retry_delay_seconds is not None:\n-            message += f\" Google suggests retrying in {retry_delay_seconds:g}s.\"\n-    elif status == 429 and err_status == \"RESOURCE_EXHAUSTED\":\n-        message = (\n-            f\"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). \"\n-            f\"Check /gquota for remaining daily requests.\"\n-        )\n-        if retry_delay_seconds is not None:\n-            message += f\" Retry suggested in {retry_delay_seconds:g}s.\"\n-    elif status == 404:\n-        # Google returns 404 when a model has been retired or renamed.\n-        target = model_hint or (err_message or \"model\")\n-        message = (\n-            f\"Code Assist 404: {target} is not available at \"\n-            f\"cloudcode-pa.googleapis.com. It may have been renamed or \"\n-            f\"retired. Check hermes_cli/models.py for the current list.\"\n-        )\n-    elif err_message:\n-        # Generic fallback with the parsed message.\n-        message = f\"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}\"\n-    else:\n-        # Last-ditch fallback — raw body snippet.\n-        message = f\"Code Assist returned HTTP {status}: {body_text[:500]}\"\n-\n-    return CodeAssistError(\n-        message,\n-        code=code,\n-        status_code=status,\n-        response=response,\n-        retry_after=retry_delay_seconds,\n-        details={\n-            \"status\": err_status,\n-            \"reason\": error_reason,\n-            \"metadata\": error_metadata,\n-            \"message\": err_message,\n-        },\n-    )"]},{"file":"/dev/null","hunks":["@@ -1,451 +0,0 @@\n-\"\"\"Google Code Assist API client — project discovery, onboarding, quota.\n-\n-The Code Assist API powers Google's official gemini-cli. It sits at\n-``cloudcode-pa.googleapis.com`` and provides:\n-\n-- Free tier access (generous daily quota) for personal Google accounts\n-- Paid tier access via GCP projects with billing / Workspace / Standard / Enterprise\n-\n-This module handles the control-plane dance needed before inference:\n-\n-1. ``load_code_assist()`` — probe the user's account to learn what tier they're on\n-   and whether a ``cloudaicompanionProject`` is already assigned.\n-2. ``onboard_user()`` — if the user hasn't been onboarded yet (new account, fresh\n-   free tier, etc.), call this with the chosen tier + project id. Supports LRO\n-   polling for slow provisioning.\n-3. ``retrieve_user_quota()`` — fetch the ``buckets[]`` array showing remaining\n-   quota per model, used by the ``/gquota`` slash command.\n-\n-VPC-SC handling: enterprise accounts under a VPC Service Controls perimeter\n-will get ``SECURITY_POLICY_VIOLATED`` on ``load_code_assist``. We catch this\n-and force the account to ``standard-tier`` so the call chain still succeeds.\n-\n-Derived from opencode-gemini-auth (MIT) and clawdbot/extensions/google. The\n-request/response shapes are specific to Google's internal Code Assist API,\n-documented nowhere public — we copy them from the reference implementations.\n-\"\"\"\n-\n-from __future__ import annotations\n-\n-import json\n-import logging\n-import time\n-import urllib.error\n-import urllib.request\n-import uuid\n-from dataclasses import dataclass, field\n-from typing import Any, Dict, List, Optional\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-# =============================================================================\n-# Constants\n-# =============================================================================\n-\n-CODE_ASSIST_ENDPOINT = \"https://cloudcode-pa.googleapis.com\"\n-\n-# Fallback endpoints tried when prod returns an error during project discovery\n-FALLBACK_ENDPOINTS = [\n-    \"https://daily-cloudcode-pa.sandbox.googleapis.com\",\n-    \"https://autopush-cloudcode-pa.sandbox.googleapis.com\",\n-]\n-\n-# Tier identifiers that Google's API uses\n-FREE_TIER_ID = \"free-tier\"\n-LEGACY_TIER_ID = \"legacy-tier\"\n-STANDARD_TIER_ID = \"standard-tier\"\n-\n-# Default HTTP headers matching gemini-cli's fingerprint.\n-# Google may reject unrecognized User-Agents on these internal endpoints.\n-_GEMINI_CLI_USER_AGENT = \"google-api-nodejs-client/9.15.1 (gzip)\"\n-_X_GOOG_API_CLIENT = \"gl-node/24.0.0\"\n-_DEFAULT_REQUEST_TIMEOUT = 30.0\n-_ONBOARDING_POLL_ATTEMPTS = 12\n-_ONBOARDING_POLL_INTERVAL_SECONDS = 5.0\n-\n-\n-class CodeAssistError(RuntimeError):\n-    \"\"\"Exception raised by the Code Assist (``cloudcode-pa``) integration.\n-\n-    Carries HTTP status / response / retry-after metadata so the agent's\n-    ``error_classifier._extract_status_code`` and the main loop's Retry-After\n-    handling (which walks ``error.response.headers``) pick up the right\n-    signals.  Without these, 429s from the OAuth path look like opaque\n-    ``RuntimeError`` and skip the rate-limit path.\n-    \"\"\"\n-\n-    def __init__(\n-        self,\n-        message: str,\n-        *,\n-        code: str = \"code_assist_error\",\n-        status_code: Optional[int] = None,\n-        response: Any = None,\n-        retry_after: Optional[float] = None,\n-        details: Optional[Dict[str, Any]] = None,\n-    ) -> None:\n-        super().__init__(message)\n-        self.code = code\n-        # ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``\n-        # so a 429 from Code Assist classifies as FailoverReason.rate_limit and\n-        # triggers the main loop's fallback_providers chain the same way SDK\n-        # errors do.\n-        self.status_code = status_code\n-        # ``response`` is the underlying ``httpx.Response`` (or a shim with a\n-        # ``.headers`` mapping and ``.json()`` method).  The main loop reads\n-        # ``error.response.headers[\"Retry-After\"]`` to honor Google's retry\n-        # hints when the backend throttles us.\n-        self.response = response\n-        # Parsed ``Retry-After`` seconds (kept separately for convenience —\n-        # Google returns retry hints in both the header and the error body's\n-        # ``google.rpc.RetryInfo`` details, and we pick whichever we found).\n-        self.retry_after = retry_after\n-        # Parsed structured error details from the Google error envelope\n-        # (e.g. ``{\"reason\": \"MODEL_CAPACITY_EXHAUSTED\", \"status\": \"RESOURCE_EXHAUSTED\"}``).\n-        # Useful for logging and for tests that want to assert on specifics.\n-        self.details = details or {}\n-\n-\n-class ProjectIdRequiredError(CodeAssistError):\n-    def __init__(self, message: str = \"GCP project id required for this tier\") -> None:\n-        super().__init__(message, code=\"code_assist_project_id_required\")\n-\n-\n-# =============================================================================\n-# HTTP primitive (auth via Bearer token passed per-call)\n-# =============================================================================\n-\n-def _build_headers(access_token: str, *, user_agent_model: str = \"\") -> Dict[str, str]:\n-    ua = _GEMINI_CLI_USER_AGENT\n-    if user_agent_model:\n-        ua = f\"{ua} model/{user_agent_model}\"\n-    return {\n-        \"Content-Type\": \"application/json\",\n-        \"Accept\": \"application/json\",\n-        \"Authorization\": f\"Bearer {access_token}\",\n-        \"User-Agent\": ua,\n-        \"X-Goog-Api-Client\": _X_GOOG_API_CLIENT,\n-        \"x-activity-request-id\": str(uuid.uuid4()),\n-    }\n-\n-\n-def _client_metadata() -> Dict[str, str]:\n-    \"\"\"Match Google's gemini-cli exactly — unrecognized metadata may be rejected.\"\"\"\n-    return {\n-        \"ideType\": \"IDE_UNSPECIFIED\",\n-        \"platform\": \"PLATFORM_UNSPECIFIED\",\n-        \"pluginType\": \"GEMINI\",\n-    }\n-\n-\n-def _post_json(\n-    url: str,\n-    body: Dict[str, Any],\n-    access_token: str,\n-    *,\n-    timeout: float = _DEFAULT_REQUEST_TIMEOUT,\n-    user_agent_model: str = \"\",\n-) -> Dict[str, Any]:\n-    data = json.dumps(body).encode(\"utf-8\")\n-    request = urllib.request.Request(\n-        url, data=data, method=\"POST\",\n-        headers=_build_headers(access_token, user_agent_model=user_agent_model),\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-        # Special case: VPC-SC violation should be distinguishable\n-        if _is_vpc_sc_violation(detail):\n-            raise CodeAssistError(\n-                f\"VPC-SC policy violation: {detail}\",\n-                code=\"code_assist_vpc_sc\",\n-            ) from exc\n-        raise CodeAssistError(\n-            f\"Code Assist HTTP {exc.code}: {detail or exc.reason}\",\n-            code=f\"code_assist_http_{exc.code}\",\n-        ) from exc\n-    except urllib.error.URLError as exc:\n-        raise CodeAssistError(\n-            f\"Code Assist request failed: {exc}\",\n-            code=\"code_assist_network_error\",\n-        ) from exc\n-\n-\n-def _is_vpc_sc_violation(body: str) -> bool:\n-    \"\"\"Detect a VPC Service Controls violation from a response body.\"\"\"\n-    if not body:\n-        return False\n-    try:\n-        parsed = json.loads(body)\n-    except (json.JSONDecodeError, ValueError):\n-        return \"SECURITY_POLICY_VIOLATED\" in body\n-    # Walk the nested error structure Google uses\n-    error = parsed.get(\"error\") if isinstance(parsed, dict) else None\n-    if not isinstance(error, dict):\n-        return False\n-    details = error.get(\"details\") or []\n-    if isinstance(details, list):\n-        for item in details:\n-            if isinstance(item, dict):\n-                reason = item.get(\"reason\") or \"\"\n-                if reason == \"SECURITY_POLICY_VIOLATED\":\n-                    return True\n-    msg = str(error.get(\"message\", \"\"))\n-    return \"SECURITY_POLICY_VIOLATED\" in msg\n-\n-\n-# =============================================================================\n-# load_code_assist — discovers current tier + assigned project\n-# =============================================================================\n-\n-@dataclass\n-class CodeAssistProjectInfo:\n-    \"\"\"Result from ``load_code_assist``.\"\"\"\n-    current_tier_id: str = \"\"\n-    cloudaicompanion_project: str = \"\"   # Google-managed project (free tier)\n-    allowed_tiers: List[str] = field(default_factory=list)\n-    raw: Dict[str, Any] = field(default_factory=dict)\n-\n-\n-def load_code_assist(\n-    access_token: str,\n-    *,\n-    project_id: str = \"\",\n-    user_agent_model: str = \"\",\n-) -> CodeAssistProjectInfo:\n-    \"\"\"Call ``POST /v1internal:loadCodeAssist`` with prod → sandbox fallback.\n-\n-    Returns whatever tier + project info Google reports. On VPC-SC violations,\n-    returns a synthetic ``standard-tier`` result so the chain can continue.\n-    \"\"\"\n-    body: Dict[str, Any] = {\n-        \"metadata\": {\n-            \"duetProject\": project_id,\n-            **_client_metadata(),\n-        },\n-    }\n-    if project_id:\n-        body[\"cloudaicompanionProject\"] = project_id\n-\n-    endpoints = [CODE_ASSIST_ENDPOINT] + FALLBACK_ENDPOINTS\n-    last_err: Optional[Exception] = None\n-    for endpoint in endpoints:\n-        url = f\"{endpoint}/v1internal:loadCodeAssist\"\n-        try:\n-            resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)\n-            return _parse_load_response(resp)\n-        except CodeAssistError as exc:\n-            if exc.code == \"code_assist_vpc_sc\":\n-                logger.info(\"VPC-SC violation on %s — defaulting to standard-tier\", endpoint)\n-                return CodeAssistProjectInfo(\n-                    current_tier_id=STANDARD_TIER_ID,\n-                    cloudaicompanion_project=project_id,\n-                )\n-            last_err = exc\n-            logger.warning(\"loadCodeAssist failed on %s: %s\", endpoint, exc)\n-            continue\n-    if last_err:\n-        raise last_err\n-    return CodeAssistProjectInfo()\n-\n-\n-def _parse_load_response(resp: Dict[str, Any]) -> CodeAssistProjectInfo:\n-    current_tier = resp.get(\"currentTier\") or {}\n-    tier_id = str(current_tier.get(\"id\") or \"\") if isinstance(current_tier, dict) else \"\"\n-    project = str(resp.get(\"cloudaicompanionProject\") or \"\")\n-    allowed = resp.get(\"allowedTiers\") or []\n-    allowed_ids: List[str] = []\n-    if isinstance(allowed, list):\n-        for t in allowed:\n-            if isinstance(t, dict):\n-                tid = str(t.get(\"id\") or \"\")\n-                if tid:\n-                    allowed_ids.append(tid)\n-    return CodeAssistProjectInfo(\n-        current_tier_id=tier_id,\n-        cloudaicompanion_project=project,\n-        allowed_tiers=allowed_ids,\n-        raw=resp,\n-    )\n-\n-\n-# =============================================================================\n-# onboard_user — provisions a new user on a tier (with LRO polling)\n-# =============================================================================\n-\n-def onboard_user(\n-    access_token: str,\n-    *,\n-    tier_id: str,\n-    project_id: str = \"\",\n-    user_agent_model: str = \"\",\n-) -> Dict[str, Any]:\n-    \"\"\"Call ``POST /v1internal:onboardUser`` to provision the user.\n-\n-    For paid tiers, ``project_id`` is REQUIRED (raises ProjectIdRequiredError).\n-    For free tiers, ``project_id`` is optional — Google will assign one.\n-\n-    Returns the final operation response. Polls ``/v1internal/<name>`` for up\n-    to ``_ONBOARDING_POLL_ATTEMPTS`` × ``_ONBOARDING_POLL_INTERVAL_SECONDS``\n-    (default: 12 × 5s = 1 min).\n-    \"\"\"\n-    if tier_id != FREE_TIER_ID and tier_id != LEGACY_TIER_ID and not project_id:\n-        raise ProjectIdRequiredError(\n-            f\"Tier {tier_id!r} requires a GCP project id. \"\n-            \"Set HERMES_GEMINI_PROJECT_ID or GOOGLE_CLOUD_PROJECT.\"\n-        )\n-\n-    body: Dict[str, Any] = {\n-        \"tierId\": tier_id,\n-        \"metadata\": _client_metadata(),\n-    }\n-    if project_id:\n-        body[\"cloudaicompanionProject\"] = project_id\n-\n-    endpoint = CODE_ASSIST_ENDPOINT\n-    url = f\"{endpoint}/v1internal:onboardUser\"\n-    resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)\n-\n-    # Poll if LRO (long-running operation)\n-    if not resp.get(\"done\"):\n-        op_name = resp.get(\"name\", \"\")\n-        if not op_name:\n-            return resp\n-        for attempt in range(_ONBOARDING_POLL_ATTEMPTS):\n-            time.sleep(_ONBOARDING_POLL_INTERVAL_SECONDS)\n-            poll_url = f\"{endpoint}/v1internal/{op_name}\"\n-            try:\n-                poll_resp = _post_json(poll_url, {}, access_token, user_agent_model=user_agent_model)\n-            except CodeAssistError as exc:\n-                logger.warning(\"Onboarding poll attempt %d failed: %s\", attempt + 1, exc)\n-                continue\n-            if poll_resp.get(\"done\"):\n-                return poll_resp\n-        logger.warning(\"Onboarding did not complete within %d attempts\", _ONBOARDING_POLL_ATTEMPTS)\n-    return resp\n-\n-\n-# =============================================================================\n-# retrieve_user_quota — for /gquota\n-# =============================================================================\n-\n-@dataclass\n-class QuotaBucket:\n-    model_id: str\n-    token_type: str = \"\"\n-    remaining_fraction: float = 0.0\n-    reset_time_iso: str = \"\"\n-    raw: Dict[str, Any] = field(default_factory=dict)\n-\n-\n-def retrieve_user_quota(\n-    access_token: str,\n-    *,\n-    project_id: str = \"\",\n-    user_agent_model: str = \"\",\n-) -> List[QuotaBucket]:\n-    \"\"\"Call ``POST /v1internal:retrieveUserQuota`` and parse ``buckets[]``.\"\"\"\n-    body: Dict[str, Any] = {}\n-    if project_id:\n-        body[\"project\"] = project_id\n-    url = f\"{CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota\"\n-    resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)\n-    raw_buckets = resp.get(\"buckets\") or []\n-    buckets: List[QuotaBucket] = []\n-    if not isinstance(raw_buckets, list):\n-        return buckets\n-    for b in raw_buckets:\n-        if not isinstance(b, dict):\n-            continue\n-        buckets.append(QuotaBucket(\n-            model_id=str(b.get(\"modelId\") or \"\"),\n-            token_type=[redacted:secret-assignment]\"tokenType\") or \"\"),\n-            remaining_fraction=float(b.get(\"remainingFraction\") or 0.0),\n-            reset_time_iso=str(b.get(\"resetTime\") or \"\"),\n-            raw=b,\n-        ))\n-    return buckets\n-\n-\n-# =============================================================================\n-# Project context resolution\n-# =============================================================================\n-\n-@dataclass\n-class ProjectContext:\n-    \"\"\"Resolved state for a given OAuth session.\"\"\"\n-    project_id: str = \"\"           # effective project id sent on requests\n-    managed_project_id: str = \"\"   # Google-assigned project (free tier)\n-    tier_id: str = \"\"\n-    source: str = \"\"               # \"env\", \"config\", \"discovered\", \"onboarded\"\n-\n-\n-def resolve_project_context(\n-    access_token: str,\n-    *,\n-    configured_project_id: str = \"\",\n-    env_project_id: str = \"\",\n-    user_agent_model: str = \"\",\n-) -> ProjectContext:\n-    \"\"\"Figure out what project id + tier to use for requests.\n-\n-    Priority:\n-      1. If configured_project_id or env_project_id is set, use that directly\n-         and short-circuit (no discovery needed).\n-      2. Otherwise call loadCodeAssist to see what Google says.\n-      3. If no tier assigned yet, onboard the user (free tier default).\n-    \"\"\"\n-    # Short-circuit: caller provided a project id\n-    if configured_project_id:\n-        return ProjectContext(\n-            project_id=configured_project_id,\n-            tier_id=STANDARD_TIER_ID,  # assume paid since they specified one\n-            source=\"config\",\n-        )\n-    if env_project_id:\n-        return ProjectContext(\n-            project_id=env_project_id,\n-            tier_id=STANDARD_TIER_ID,\n-            source=\"env\",\n-        )\n-\n-    # Discover via loadCodeAssist\n-    info = load_code_assist(access_token, user_agent_model=user_agent_model)\n-\n-    effective_project = info.cloudaicompanion_project\n-    tier = info.current_tier_id\n-\n-    if not tier:\n-        # User hasn't been onboarded — provision them on free tier\n-        onboard_resp = onboard_user(\n-            access_token,\n-            tier_id=FREE_TIER_ID,\n-            project_id=\"\",\n-            user_agent_model=user_agent_model,\n-        )\n-        # Re-parse from the onboard response\n-        response_body = onboard_resp.get(\"response\") or {}\n-        if isinstance(response_body, dict):\n-            effective_project = (\n-                effective_project\n-                or str(response_body.get(\"cloudaicompanionProject\") or \"\")\n-            )\n-        tier = FREE_TIER_ID\n-        source = \"onboarded\"\n-    else:\n-        source = \"discovered\"\n-\n-    return ProjectContext(\n-        project_id=effective_project,\n-        managed_project_id=effective_project if tier == FREE_TIER_ID else \"\",\n-        tier_id=tier,\n-        source=source,\n-    )"]},{"file":"/dev/null","hunks":["@@ -1,1067 +0,0 @@\n-\"\"\"Google OAuth PKCE flow for the Gemini (google-gemini-cli) inference provider.\n-\n-This module implements Authorization Code + PKCE (S256) OAuth against Google's\n-accounts.google.com endpoints. The resulting access token is used by\n-``agent.gemini_cloudcode_adapter`` to talk to ``cloudcode-pa.googleapis.com``\n-(Google's Code Assist backend that powers the Gemini CLI's free and paid tiers).\n-\n-Synthesized from:\n-- jenslys/opencode-gemini-auth (MIT) — overall flow shape, public OAuth creds, request format\n-- clawdbot/extensions/google/ — refresh-token rotation, VPC-SC handling reference\n-- PRs #10176 (@sliverp) and #10779 (@newarthur) — PKCE module structure, cross-process lock\n-\n-Storage (``~/.hermes/auth/google_oauth.json``, chmod 0o600):\n-\n-    {\n-      \"refresh\": \"refreshToken|projectId|managedProjectId\",\n-      \"access\": \"...\",\n-      \"expires\": 1744848000000,   // unix MILLIseconds\n-      \"email\": \"[redacted:email]\"\n-    }\n-\n-The ``refresh`` field packs the refresh_token together with the resolved GCP\n-project IDs so subsequent sessions don't need to re-discover the project.\n-This matches opencode-gemini-auth's storage contract exactly.\n-\n-The packed format stays parseable even if no project IDs are present — just\n-a bare refresh_token is treated as \"packed with empty IDs\".\n-\n-Public client credentials\n--------------------------\n-The client_id and client_secret below are Google's PUBLIC desktop OAuth client\n-for their own open-source gemini-cli. They are baked into every copy of the\n-gemini-cli npm package and are NOT confidential — desktop OAuth clients have\n-no secret-keeping requirement (PKCE provides the security). Shipping them here\n-is consistent with opencode-gemini-auth and the official Google gemini-cli.\n-\n-Policy note: Google considers using this OAuth client with third-party software\n-a policy violation. Users see an upfront warning with ``confirm(default=False)``\n-before authorization begins.\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 secrets\n-import stat\n-import threading\n-import time\n-import urllib.error\n-import urllib.parse\n-import urllib.request\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, secure_parent_dir\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-# =============================================================================\n-# OAuth client credential resolution.\n-#\n-# Resolution order:\n-#   1. HERMES_GEMINI_CLIENT_ID / HERMES_GEMINI_CLIENT_SECRET env vars (power users)\n-#   2. Shipped defaults — Google's public gemini-cli desktop OAuth client\n-#      (baked into every copy of Google's open-source gemini-cli; NOT\n-#      confidential — desktop OAuth clients use PKCE, not client_secret, for\n-#      security). Using these matches opencode-gemini-auth behavior.\n-#   3. Fallback: scrape from a locally installed gemini-cli binary (helps forks\n-#      that deliberately wipe the shipped defaults).\n-#   4. Fail with a helpful error.\n-# =============================================================================\n-\n-ENV_CLIENT_ID = \"HERMES_GEMINI_CLIENT_ID\"\n-ENV_CLIENT_SECRET = \"[redacted:secret-assignment]\"\n-\n-# Public gemini-cli desktop OAuth client (shipped in Google's open-source\n-# gemini-cli MIT repo). Composed piecewise to keep the constants readable and\n-# to pair each piece with an explicit comment about why it is non-confidential.\n-# See: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts\n-_PUBLIC_CLIENT_ID_PROJECT_NUM = \"681255809395\"\n-_PUBLIC_CLIENT_ID_HASH = \"oo8ft2oprdrnp9e3aqf6av3hmdib135j\"\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-# Regex patterns for fallback scraping from an installed gemini-cli.\n-import re as _re\n-from utils import atomic_replace\n-_CLIENT_ID_PATTERN = _re.compile(\n-    r\"OAUTH_CLIENT_ID\\s*=\\s*['\\\"]([0-9]+-[a-z0-9]+\\.apps\\.googleusercontent\\.com)['\\\"]\"\n-)\n-_CLIENT_SECRET_PATTERN = [redacted:secret-assignment]\n-    r\"OAUTH_CLIENT_SECRET\\s*=\\s*['\\\"](GOCSPX-[A-Za-z0-9_-]+)['\\\"]\"\n-)\n-_CLIENT_ID_SHAPE = _re.compile(r\"([0-9]{8,}-[a-z0-9]{20,}\\.apps\\.googleusercontent\\.com)\")\n-_CLIENT_SECRET_SHAPE = [redacted:secret-assignment]\"(GOCSPX-[A-Za-z0-9_-]{20,})\")\n-\n-\n-# =============================================================================\n-# Endpoints & constants\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-)\n-\n-DEFAULT_REDIRECT_PORT = 8085\n-REDIRECT_HOST = \"127.0.0.1\"\n-CALLBACK_PATH = \"/oauth2callback\"\n-\n-# 60-second clock skew buffer (matches opencode-gemini-auth).\n-REFRESH_SKEW_SECONDS = 60\n-\n-TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0\n-CALLBACK_WAIT_SECONDS = 300\n-LOCK_TIMEOUT_SECONDS = 30.0\n-\n-# Headless env detection\n-_HEADLESS_ENV_VARS = (\"SSH_CONNECTION\", \"SSH_CLIENT\", \"SSH_TTY\", \"HERMES_HEADLESS\")\n-\n-\n-# =============================================================================\n-# Error type\n-# =============================================================================\n-\n-class GoogleOAuthError(RuntimeError):\n-    \"\"\"Raised for any failure in the Google OAuth flow.\"\"\"\n-\n-    def __init__(self, message: str, *, code: str = \"google_oauth_error\") -> None:\n-        super().__init__(message)\n-        self.code = code\n-\n-\n-# =============================================================================\n-# File paths & cross-process locking\n-# =============================================================================\n-\n-def _credentials_path() -> Path:\n-    return get_hermes_home() / \"auth\" / \"google_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-    \"\"\"Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows).\"\"\"\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 Google 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 Google 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-# =============================================================================\n-# Client ID resolution\n-# =============================================================================\n-\n-_scraped_creds_cache: Dict[str, str] = {}\n-\n-\n-def _locate_gemini_cli_oauth_js() -> Optional[Path]:\n-    \"\"\"Walk the user's gemini binary install to find its oauth2.js.\n-\n-    Returns None if gemini isn't installed. Supports both the npm install\n-    (``node_modules/@google/gemini-cli-core/dist/**/code_assist/oauth2.js``)\n-    and the Homebrew ``bundle/`` layout.\n-    \"\"\"\n-    import shutil\n-\n-    gemini = shutil.which(\"gemini\")\n-    if not gemini:\n-        return None\n-\n-    try:\n-        real = Path(gemini).resolve()\n-    except OSError:\n-        return None\n-\n-    # Walk up from the binary to find npm install root\n-    search_dirs: list[Path] = []\n-    cur = real.parent\n-    for _ in range(8):  # don't walk too far\n-        search_dirs.append(cur)\n-        if (cur / \"node_modules\").exists():\n-            search_dirs.append(cur / \"node_modules\" / \"@google\" / \"gemini-cli-core\")\n-            break\n-        if cur.parent == cur:\n-            break\n-        cur = cur.parent\n-\n-    for root in search_dirs:\n-        if not root.exists():\n-            continue\n-        # Common known paths\n-        candidates = [\n-            root / \"dist\" / \"src\" / \"code_assist\" / \"oauth2.js\",\n-            root / \"dist\" / \"code_assist\" / \"oauth2.js\",\n-            root / \"src\" / \"code_assist\" / \"oauth2.js\",\n-        ]\n-        for c in candidates:\n-            if c.exists():\n-                return c\n-        # Recursive fallback: look for oauth2.js within 10 dirs deep\n-        try:\n-            for path in root.rglob(\"oauth2.js\"):\n-                return path\n-        except (OSError, ValueError):\n-            continue\n-\n-    return None\n-\n-\n-def _scrape_client_credentials() -> Tuple[str, str]:\n-    \"\"\"Extract client_id + client_secret from the local gemini-cli install.\"\"\"\n-    if _scraped_creds_cache.get(\"resolved\"):\n-        return _scraped_creds_cache.get(\"client_id\", \"\"), _scraped_creds_cache.get(\"client_secret\", \"\")\n-\n-    oauth_js = _locate_gemini_cli_oauth_js()\n-    if oauth_js is None:\n-        _scraped_creds_cache[\"resolved\"] = \"1\"  # Don't retry on every call\n-        return \"\", \"\"\n-\n-    try:\n-        content = oauth_js.read_text(encoding=\"utf-8\", errors=\"replace\")\n-    except OSError as exc:\n-        logger.debug(\"Failed to read oauth2.js at %s: %s\", oauth_js, exc)\n-        _scraped_creds_cache[\"resolved\"] = \"1\"\n-        return \"\", \"\"\n-\n-    # Precise pattern first, then fallback shape match\n-    cid_match = _CLIENT_ID_PATTERN.search(content) or _CLIENT_ID_SHAPE.search(content)\n-    cs_match = _CLIENT_SECRET_PATTERN.search(content) or _CLIENT_SECRET_SHAPE.search(content)\n-\n-    client_id = cid_match.group(1) if cid_match else \"\"\n-    client_secret = [redacted:secret-assignment] if cs_match else \"\"\n-\n-    _scraped_creds_cache[\"client_id\"] = client_id\n-    _scraped_creds_cache[\"client_secret\"] = client_secret\n-    _scraped_creds_cache[\"resolved\"] = \"1\"\n-\n-    if client_id:\n-        logger.info(\"Scraped Gemini OAuth client from %s\", oauth_js)\n-\n-    return client_id, client_secret\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-    if _DEFAULT_CLIENT_ID:\n-        return _DEFAULT_CLIENT_ID\n-    scraped, _ = _scrape_client_credentials()\n-    return scraped\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-    if _DEFAULT_CLIENT_SECRET:\n-        return _DEFAULT_CLIENT_SECRET\n-    _, scraped = _scrape_client_credentials()\n-    return scraped\n-\n-\n-def _require_client_id() -> str:\n-    cid = _get_client_id()\n-    if not cid:\n-        raise GoogleOAuthError(\n-            \"Google OAuth client ID is not available.\\n\"\n-            \"Hermes looks for a locally installed gemini-cli to source the OAuth client. \"\n-            \"Either:\\n\"\n-            \"  1. Install it: npm install -g @google/gemini-cli  (or brew install gemini-cli)\\n\"\n-            \"  2. Set HERMES_GEMINI_CLIENT_ID and HERMES_GEMINI_CLIENT_SECRET in ~/.hermes/.env\\n\"\n-            \"\\n\"\n-            \"Register a Desktop OAuth client at:\\n\"\n-            \"  https://console.cloud.google.com/apis/credentials\\n\"\n-            \"(enable the Generative Language API on the project).\",\n-            code=\"google_oauth_client_id_missing\",\n-        )\n-    return cid\n-\n-\n-# =============================================================================\n-# PKCE\n-# =============================================================================\n-\n-def _generate_pkce_pair() -> Tuple[str, str]:\n-    \"\"\"Generate a (verifier, challenge) pair using S256.\"\"\"\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-# =============================================================================\n-# Packed refresh format:  refresh_token[|project_id[|managed_project_id]]\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-# =============================================================================\n-# Credentials (dataclass wrapping the on-disk format)\n-# =============================================================================\n-\n-@dataclass\n-class GoogleCredentials:\n-    access_token: str\n-    refresh_token: str\n-    expires_ms: int  # unix milliseconds\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]) -> \"GoogleCredentials\":\n-        refresh_packed = str(data.get(\"refresh\", \"\") or \"\")\n-        parts = RefreshParts.parse(refresh_packed)\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 expires_unix_seconds(self) -> float:\n-        return self.expires_ms / 1000.0\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-# =============================================================================\n-# Credential I/O (atomic + locked)\n-# =============================================================================\n-\n-def load_credentials() -> Optional[GoogleCredentials]:\n-    \"\"\"Load credentials from disk. Returns None if missing or corrupt.\"\"\"\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 Google OAuth credentials at %s: %s\", path, exc)\n-        return None\n-    if not isinstance(data, dict):\n-        return None\n-    creds = GoogleCredentials.from_dict(data)\n-    if not creds.access_token:\n-        return None\n-    return creds\n-\n-\n-def save_credentials(creds: GoogleCredentials) -> Path:\n-    \"\"\"Atomically write creds to disk with 0o600 permissions.\"\"\"\n-    path = _credentials_path()\n-    path.parent.mkdir(parents=True, exist_ok=True)\n-    # Tighten parent dir to 0o700 so siblings can't traverse to the creds file.\n-    # On Windows this is a no-op (POSIX mode bits aren't enforced); ignore failures.\n-    # secure_parent_dir refuses to chmod / or top-level dirs (#25821).\n-    secure_parent_dir(path)\n-    payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + \"\\n\"\n-\n-    with _credentials_lock():\n-        tmp_path = path.with_suffix(f\".tmp.{os.getpid()}.{secrets.token_hex(4)}\")\n-        try:\n-            # Create with 0o600 atomically to close the TOCTOU window where the\n-            # default umask (often 0o644) would briefly expose tokens to other\n-            # local users between open() and chmod().\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-    \"\"\"Remove the creds file. Idempotent.\"\"\"\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 Google OAuth credentials at %s: %s\", path, exc)\n-\n-\n-# =============================================================================\n-# HTTP helpers\n-# =============================================================================\n-\n-def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:\n-    \"\"\"POST x-www-form-urlencoded and return parsed JSON response.\"\"\"\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-        # Detect invalid_grant to signal credential revocation\n-        code = \"google_oauth_token_http_error\"\n-        if \"invalid_grant\" in detail.lower():\n-            code = \"google_oauth_invalid_grant\"\n-        raise GoogleOAuthError(\n-            f\"Google 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 GoogleOAuthError(\n-            f\"Google OAuth token request failed: {exc}\",\n-            code=\"google_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-    client_id: Optional[str] = None,\n-    client_secret: [redacted:secret-assignment] = None,\n-    timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,\n-) -> Dict[str, Any]:\n-    \"\"\"Exchange authorization code for access + refresh tokens.\"\"\"\n-    cid = client_id if client_id is not None else _get_client_id()\n-    csecret = [redacted:secret-assignment] if client_secret is not None else _get_client_secret()\n-    data = {\n-        \"grant_type\": \"authorization_code\",\n-        \"code\": code,\n-        \"code_verifier\": verifier,\n-        \"client_id\": cid,\n-        \"redirect_uri\": redirect_uri,\n-    }\n-    if csecret:\n-        data[\"client_secret\"] = csecret\n-    return _post_form(TOKEN_ENDPOINT, data, timeout)\n-\n-\n-def refresh_access_token(\n-    refresh_token: str,\n-    *,\n-    client_id: Optional[str] = None,\n-    client_secret: [redacted:secret-assignment] = None,\n-    timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,\n-) -> Dict[str, Any]:\n-    \"\"\"Refresh the access token.\"\"\"\n-    if not refresh_token:\n-        raise GoogleOAuthError(\n-            \"Cannot refresh: refresh_token is empty. Re-run OAuth login.\",\n-            code=\"google_oauth_refresh_token_missing\",\n-        )\n-    cid = client_id if client_id is not None else _get_client_id()\n-    csecret = [redacted:secret-assignment] if client_secret is not None else _get_client_secret()\n-    data = {\n-        \"grant_type\": \"refresh_token\",\n-        \"refresh_token\": refresh_token,\n-        \"client_id\": cid,\n-    }\n-    if csecret:\n-        data[\"client_secret\"] = csecret\n-    return _post_form(TOKEN_ENDPOINT, data, timeout)\n-\n-\n-def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:\n-    \"\"\"Best-effort userinfo fetch for display. Failures return empty string.\"\"\"\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(\"Userinfo fetch failed (non-fatal): %s\", exc)\n-        return \"\"\n-\n-\n-# =============================================================================\n-# In-flight refresh deduplication\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-    \"\"\"Load creds, refreshing if near expiry, and return a valid bearer token.\n-\n-    Dedupes concurrent refreshes by refresh_token. On ``invalid_grant``, the\n-    credential file is wiped and a ``google_oauth_invalid_grant`` error is raised\n-    (caller is expected to trigger a re-login flow).\n-    \"\"\"\n-    creds = load_credentials()\n-    if creds is None:\n-        raise GoogleOAuthError(\n-            \"No Google OAuth credentials found. Run `hermes auth add google-gemini-cli` first.\",\n-            code=\"google_oauth_not_logged_in\",\n-        )\n-\n-    if not force_refresh and not creds.access_token_expired():\n-        return creds.access_token\n-\n-    # Dedupe concurrent refreshes by refresh_token\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-        # Another thread is refreshing — wait, then re-read from disk.\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-        # Fall through to do our own refresh if the other attempt failed\n-\n-    try:\n-        try:\n-            resp = refresh_access_token(rt)\n-        except GoogleOAuthError as exc:\n-            if exc.code == \"google_oauth_invalid_grant\":\n-                logger.warning(\n-                    \"Google OAuth refresh token invalid (revoked/expired). \"\n-                    \"Clearing credentials at %s — user must re-login.\",\n-                    _credentials_path(),\n-                )\n-                clear_credentials()\n-            raise\n-\n-        new_access = str(resp.get(\"access_token\", \"\") or \"\").strip()\n-        if not new_access:\n-            raise GoogleOAuthError(\n-                \"Refresh response did not include an access_token.\",\n-                code=\"google_oauth_refresh_empty\",\n-            )\n-        # Google sometimes rotates refresh_token; preserve existing if omitted.\n-        new_refresh = str(resp.get(\"refresh_token\", \"\") or \"\").strip() or creds.refresh_token\n-        expires_in = int(resp.get(\"expires_in\", 0) or 0)\n-\n-        creds.access_token = [redacted:secret-assignment]\n-        creds.refresh_token = [redacted:secret-assignment]\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-# =============================================================================\n-# Update project IDs on stored creds\n-# =============================================================================\n-\n-def update_project_ids(project_id: str = \"\", managed_project_id: str = \"\") -> None:\n-    \"\"\"Persist resolved/discovered project IDs back into the credential file.\"\"\"\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-# =============================================================================\n-# Callback server\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(\"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-        if state != type(self).expected_state:\n-            type(self).captured_error = \"state_mismatch\"\n-            self._respond_html(400, _ERROR_PAGE.format(message=\"State mismatch — aborting for safety.\"))\n-        elif error:\n-            type(self).captured_error = error\n-            # Simple HTML-escape of the error value\n-            safe_err = (\n-                str(error)\n-                .replace(\"&\", \"&amp;\")\n-                .replace(\"<\", \"&lt;\")\n-                .replace(\">\", \"&gt;\")\n-            )\n-            self._respond_html(400, _ERROR_PAGE.format(message=f\"Authorization denied: {safe_err}\"))\n-        elif code:\n-            type(self).captured_code = code\n-            self._respond_html(200, _SUCCESS_PAGE)\n-        else:\n-            type(self).captured_error = \"no_code\"\n-            self._respond_html(400, _ERROR_PAGE.format(message=\"Callback received no authorization code.\"))\n-\n-        if type(self).ready is not None:\n-            type(self).ready.set()\n-\n-    def _respond_html(self, status: int, body: str) -> None:\n-        payload = body.encode(\"utf-8\")\n-        self.send_response(status)\n-        self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n-        self.send_header(\"Content-Length\", str(len(payload)))\n-        self.end_headers()\n-        self.wfile.write(payload)\n-\n-\n-_SUCCESS_PAGE = \"\"\"<!doctype html>\n-<html><head><meta charset=\"utf-8\"><title>Hermes — signed in</title>\n-<style>\n-body { font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }\n-h1 { color: #1a7f37; } p { color: #555; }\n-</style></head>\n-<body><h1>Signed in to Google.</h1>\n-<p>You can close this tab and return to your terminal.</p></body></html>\n-\"\"\"\n-\n-_ERROR_PAGE = \"\"\"<!doctype html>\n-<html><head><meta charset=\"utf-8\"><title>Hermes — sign-in failed</title>\n-<style>\n-body {{ font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }}\n-h1 {{ color: #b42318; }} p {{ color: #555; }}\n-</style></head>\n-<body><h1>Sign-in failed</h1><p>{message}</p>\n-<p>Return to your terminal — Hermes will walk you through a manual paste fallback.</p></body></html>\n-\"\"\"\n-\n-\n-def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]:\n-    try:\n-        server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler)\n-        return server, preferred_port\n-    except OSError as exc:\n-        logger.info(\n-            \"Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port\",\n-            preferred_port, exc,\n-        )\n-    server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler)\n-    return server, server.server_address[1]\n-\n-\n-def _is_headless() -> bool:\n-    return any(os.getenv(k) for k in _HEADLESS_ENV_VARS)\n-\n-\n-# =============================================================================\n-# Main login flow\n-# =============================================================================\n-\n-def start_oauth_flow(\n-    *,\n-    force_relogin: bool = False,\n-    open_browser: bool = True,\n-    callback_wait_seconds: float = CALLBACK_WAIT_SECONDS,\n-    project_id: str = \"\",\n-) -> GoogleCredentials:\n-    \"\"\"Run the interactive browser OAuth flow and persist credentials.\n-\n-    Args:\n-        force_relogin: If False and valid creds already exist, return them.\n-        open_browser: If False, skip webbrowser.open and print the URL only.\n-        callback_wait_seconds: Max seconds to wait for the browser callback.\n-        project_id: Initial GCP project ID to bake into the stored creds.\n-                    Can be discovered/updated later via update_project_ids().\n-    \"\"\"\n-    if not force_relogin:\n-        existing = load_credentials()\n-        if existing and existing.access_token:\n-            logger.info(\"Google OAuth credentials already present; skipping login.\")\n-            return existing\n-\n-    client_id = _require_client_id()  # raises GoogleOAuthError with install hints\n-    client_secret = [redacted:secret-assignment]\n-\n-    verifier, challenge = _generate_pkce_pair()\n-    state = secrets.token_urlsafe(16)\n-\n-    # If headless, skip the listener and go straight to paste mode\n-    if _is_headless() and open_browser:\n-        logger.info(\"Headless environment detected; using paste-mode OAuth fallback.\")\n-        return _paste_mode_login(verifier, challenge, state, client_id, client_secret, project_id)\n-\n-    server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT)\n-    redirect_uri = f\"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}\"\n-\n-    _OAuthCallbackHandler.expected_state = state\n-    _OAuthCallbackHandler.captured_code = None\n-    _OAuthCallbackHandler.captured_error = None\n-    ready = threading.Event()\n-    _OAuthCallbackHandler.ready = ready\n-\n-    params = {\n-        \"client_id\": client_id,\n-        \"redirect_uri\": redirect_uri,\n-        \"response_type\": \"code\",\n-        \"scope\": OAUTH_SCOPES,\n-        \"state\": state,\n-        \"code_challenge\": challenge,\n-        \"code_challenge_method\": \"S256\",\n-        \"access_type\": \"offline\",\n-        \"prompt\": \"consent\",\n-    }\n-    auth_url = AUTH_ENDPOINT + \"?\" + urllib.parse.urlencode(params) + \"#hermes\"\n-\n-    server_thread = threading.Thread(target=server.serve_forever, daemon=True)\n-    server_thread.start()\n-\n-    print()\n-    print(\"Opening your browser to sign in to Google…\")\n-    print(f\"If it does not open automatically, visit:\\n  {auth_url}\")\n-    print()\n-\n-    if open_browser:\n-        try:\n-            import webbrowser\n-\n-            try:\n-                from hermes_cli.auth import (\n-                    _can_open_graphical_browser as _can_open_gui,\n-                )\n-            except Exception:\n-                _can_open_gui = lambda: True  # noqa: E731\n-\n-            if _can_open_gui():\n-                webbrowser.open(auth_url, new=1, autoraise=True)\n-        except Exception as exc:\n-            logger.debug(\"webbrowser.open failed: %s\", exc)\n-\n-    code: Optional[str] = None\n-    try:\n-        if ready.wait(timeout=callback_wait_seconds):\n-            code = _OAuthCallbackHandler.captured_code\n-            error = _OAuthCallbackHandler.captured_error\n-            if error:\n-                raise GoogleOAuthError(\n-                    f\"Authorization failed: {error}\",\n-                    code=\"google_oauth_authorization_failed\",\n-                )\n-        else:\n-            logger.info(\"Callback server timed out — offering manual paste fallback.\")\n-            code = _prompt_paste_fallback()\n-    finally:\n-        try:\n-            server.shutdown()\n-        except Exception:\n-            pass\n-        try:\n-            server.server_close()\n-        except Exception:\n-            pass\n-        server_thread.join(timeout=2.0)\n-\n-    if not code:\n-        raise GoogleOAuthError(\n-            \"No authorization code received. Aborting.\",\n-            code=\"google_oauth_no_code\",\n-        )\n-\n-    token_resp = [redacted:secret-assignment]\n-        code, verifier, redirect_uri,\n-        client_id=client_id, client_secret=[redacted:secret-assignment]\n-    )\n-    return _persist_token_response(token_resp, project_id=project_id)\n-\n-\n-def _paste_mode_login(\n-    verifier: str,\n-    challenge: str,\n-    state: str,\n-    client_id: str,\n-    client_secret: str,\n-    project_id: str,\n-) -> GoogleCredentials:\n-    \"\"\"Run OAuth flow without a local callback server.\"\"\"\n-    # Use a placeholder redirect URI; user will paste the full URL back\n-    redirect_uri = f\"http://{REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{CALLBACK_PATH}\"\n-    params = {\n-        \"client_id\": client_id,\n-        \"redirect_uri\": redirect_uri,\n-        \"response_type\": \"code\",\n-        \"scope\": OAUTH_SCOPES,\n-        \"state\": state,\n-        \"code_challenge\": challenge,\n-        \"code_challenge_method\": \"S256\",\n-        \"access_type\": \"offline\",\n-        \"prompt\": \"consent\",\n-    }\n-    auth_url = AUTH_ENDPOINT + \"?\" + urllib.parse.urlencode(params) + \"#hermes\"\n-\n-    print()\n-    print(\"Open this URL in a browser on any device:\")\n-    print(f\"  {auth_url}\")\n-    print()\n-    print(\"After signing in, Google will redirect to localhost (which won't load).\")\n-    print(\"Copy the full URL from your browser and paste it below.\")\n-    print()\n-\n-    code = _prompt_paste_fallback()\n-    if not code:\n-        raise GoogleOAuthError(\"No authorization code provided.\", code=\"google_oauth_no_code\")\n-\n-    token_resp = [redacted:secret-assignment]\n-        code, verifier, redirect_uri,\n-        client_id=client_id, client_secret=[redacted:secret-assignment]\n-    )\n-    return _persist_token_response(token_resp, project_id=project_id)\n-\n-\n-def _prompt_paste_fallback() -> Optional[str]:\n-    print()\n-    print(\"Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.\")\n-    raw = input(\"Callback URL or code: \").strip()\n-    if not raw:\n-        return None\n-    if raw.startswith(\"http://\") or raw.startswith(\"https://\"):\n-        parsed = urllib.parse.urlparse(raw)\n-        params = urllib.parse.parse_qs(parsed.query)\n-        return (params.get(\"code\") or [\"\"])[0] or None\n-    # Accept a bare query string as well\n-    if raw.startswith(\"?\"):\n-        params = urllib.parse.parse_qs(raw[1:])\n-        return (params.get(\"code\") or [\"\"])[0] or None\n-    return raw\n-\n-\n-def _persist_token_response(\n-    token_resp: [redacted:secret-assignment] Any],\n-    *,\n-    project_id: str = \"\",\n-) -> GoogleCredentials:\n-    access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n-    refresh_token = [redacted:secret-assignment]\"refresh_token\", \"\") or \"\").strip()\n-    expires_in = int(token_resp.get(\"expires_in\", 0) or 0)\n-    if not access_token or not refresh_token:\n-        raise GoogleOAuthError(\n-            \"Google token response missing access_token or refresh_token.\",\n-            code=\"google_oauth_incomplete_token_response\",\n-        )\n-    creds = GoogleCredentials(\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-        managed_project_id=\"\",\n-    )\n-    save_credentials(creds)\n-    logger.info(\"Google OAuth credentials saved to %s\", _credentials_path())\n-    return creds\n-\n-\n-# =============================================================================\n-# Pool-compatible variant\n-# =============================================================================\n-\n-def run_gemini_oauth_login_pure() -> Dict[str, Any]:\n-    \"\"\"Run the login flow and return a dict matching the credential pool shape.\"\"\"\n-    creds = start_oauth_flow(force_relogin=True)\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-    }\n-\n-\n-# =============================================================================\n-# Project ID resolution\n-# =============================================================================\n-\n-def resolve_project_id_from_env() -> str:\n-    \"\"\"Return a GCP project ID from env vars, in priority order.\"\"\"\n-    for var in (\n-        \"HERMES_GEMINI_PROJECT_ID\",\n-        \"GOOGLE_CLOUD_PROJECT\",\n-        \"GOOGLE_CLOUD_PROJECT_ID\",\n-    ):\n-        val = (os.getenv(var) or \"\").strip()\n-        if val:\n-            return val\n-    return \"\""]},{"file":"agent/transports/chat_completions.py","hunks":["@@ -437,10 +437,6 @@ 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 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\n \n         # Merge any pre-built extra_body additions\n         additions = params.get(\"extra_body_additions\")"]},{"file":"apps/desktop/src/app/settings/constants.ts","hunks":["@@ -74,7 +74,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [\n     priority: 4\n   },\n   { prefix: 'GEMINI_', name: 'Gemini', priority: 4 },\n-  { prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },\n   {\n     prefix: 'DEEPSEEK_',\n     name: 'DeepSeek',"]},{"file":"apps/desktop/src/app/settings/helpers.test.ts","hunks":["@@ -132,9 +132,9 @@ describe('settings helpers', () => {\n       // KIMI_CN_ likewise must beat KIMI_.\n       expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')\n       expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')\n-      // HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.\n+      // HERMES_QWEN_ shares the HERMES_ stem with other integrations.\n       expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')\n-      expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')\n+      expect(providerGroup('GEMINI_API_KEY')).toBe('Gemini')\n     })\n \n     it('falls back to \"Other\" for un-grouped env vars', () => {"]},{"file":"apps/desktop/src/lib/desktop-slash-commands.ts","hunks":["@@ -150,7 +150,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [\n const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {\n   terminal: [\n     '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',\n-    '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',\n+    '/exit', '/footer', '/gateway', '/history', '/image', '/indicator', '/logs',\n     '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',\n     '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'\n   ],"]},{"file":"cli.py","hunks":["@@ -7837,8 +7837,6 @@ def process_command(self, command: str) -> bool:\n             self._handle_model_switch(cmd_original)\n         elif canonical == \"codex-runtime\":\n             self._handle_codex_runtime(cmd_original)\n-        elif canonical == \"gquota\":\n-            self._handle_gquota_command(cmd_original)\n \n         elif canonical == \"personality\":\n             # Use original case (handler lowercases the personality name itself)"]},{"file":"hermes_cli/auth.py","hunks":["@@ -138,13 +138,6 @@\n     \"spotify\": \"Spotify\",\n }\n \n-# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)\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","@@ -209,18 +202,6 @@ class ProviderConfig:\n         auth_type=\"oauth_external\",\n         inference_base_url=DEFAULT_QWEN_BASE_URL,\n     ),\n-    \"google-gemini-cli\": ProviderConfig(\n-        id=\"google-gemini-cli\",\n-        name=\"Google Gemini (OAuth)\",\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\",","@@ -1538,8 +1519,7 @@ def resolve_provider(\n         \"github-models\": \"copilot\", \"github-model\": \"copilot\",\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+        \"qwen-portal\": \"qwen-oauth\", \"qwen-cli\": \"qwen-oauth\", \"qwen-oauth\": \"qwen-oauth\",\n         \"hf\": \"huggingface\", \"hugging-face\": \"huggingface\", \"huggingface-hub\": \"huggingface\",\n         \"mimo\": \"xiaomi\", \"xiaomi-mimo\": \"xiaomi\",\n         \"tencent\": \"tencent-tokenhub\", \"tokenhub\": \"tencent-tokenhub\",","@@ -2165,163 +2145,6 @@ def get_qwen_auth_status() -> Dict[str, Any]:\n \n \n # =============================================================================\n-# Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist.\n-#\n-# Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth).\n-# The `base_url` here is the marker \"cloudcode-pa://google\" that run_agent.py\n-# uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK.\n-# Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*.\n-# =============================================================================\n-\n-def _mark_google_gemini_cli_active(creds: Dict[str, Any]) -> None:\n-    \"\"\"Set active_provider to google-gemini-cli in auth.json.\n-\n-    The actual OAuth tokens live in the Google credential file managed by\n-    agent.google_oauth. This function only writes a minimal provider-state\n-    entry (email for display) and sets active_provider so that\n-    get_active_provider() and _model_section_has_credentials() detect the\n-    provider for the setup wizard and status commands.\n-    \"\"\"\n-    with _auth_store_lock():\n-        auth_store = _load_auth_store()\n-        state: Dict[str, Any] = {}\n-        if creds.get(\"email\"):\n-            state[\"email\"] = str(creds[\"email\"])\n-        _save_provider_state(auth_store, \"google-gemini-cli\", state)\n-        _save_auth_store(auth_store)\n-\n-\n-def resolve_gemini_oauth_runtime_credentials(\n-    *,\n-    force_refresh: bool = False,\n-) -> Dict[str, Any]:\n-    \"\"\"Resolve runtime OAuth creds for google-gemini-cli.\"\"\"\n-    try:\n-        from agent.google_oauth import (\n-            GoogleOAuthError,\n-            _credentials_path,\n-            get_valid_access_token,\n-            load_credentials,\n-        )\n-    except ImportError as exc:\n-        raise AuthError(\n-            f\"agent.google_oauth is not importable: {exc}\",\n-            provider=\"google-gemini-cli\",\n-            code=\"google_oauth_module_missing\",\n-        ) from exc\n-\n-    try:\n-        access_token = [redacted:secret-assignment]\n-    except GoogleOAuthError as exc:\n-        raise AuthError(\n-            str(exc),\n-            provider=\"google-gemini-cli\",\n-            code=exc.code,\n-        ) from exc\n-\n-    creds = load_credentials()\n-    base_url = DEFAULT_GEMINI_CLOUDCODE_BASE_URL\n-    return {\n-        \"provider\": \"google-gemini-cli\",\n-        \"base_url\": base_url,\n-        \"api_key\": access_token,\n-        \"source\": \"google-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_gemini_oauth_auth_status() -> Dict[str, Any]:\n-    \"\"\"Return a status dict for `hermes auth list` / `hermes status`.\"\"\"\n-    try:\n-        from agent.google_oauth import _credentials_path, load_credentials\n-    except ImportError:\n-        return {\"logged_in\": False, \"error\": \"agent.google_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\": \"google-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-\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 ","@@ -6265,10 +6088,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n         return get_xai_oauth_auth_status()\n     if target == \"qwen-oauth\":\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\", \"google-antigravity\", \"minimax-oauth\"}\n+_OAUTH_CAPABLE_PROVIDERS = {\"anthropic\", \"nous\", \"openai-codex\", \"xai-oauth\", \"qwen-oauth\", \"minimax-oauth\"}\n \n \n def _get_custom_provider_names() -> list:","@@ -314,7 +314,7 @@ def auth_add_command(args) -> None:\n             _oauth_default_label(provider, len(pool.entries()) + 1),\n         )\n         # Add a distinct, self-contained pool entry per account (matching the\n-        # xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of\n+        # xai-oauth / qwen-oauth patterns) instead of\n         # routing through the singleton ``_save_codex_tokens`` save path.\n         # The singleton round-trip collapsed every added account into the\n         # latest login: a second ``hermes auth add openai-codex`` overwrote","@@ -364,49 +364,6 @@ def auth_add_command(args) -> None:\n         print(f'Saved {provider} OAuth credentials: \"[redacted:secret-assignment]\"')\n         return\n \n-    if provider == \"google-gemini-cli\":\n-        from agent.google_oauth import run_gemini_oauth_login_pure\n-\n-        creds = run_gemini_oauth_login_pure()\n-        auth_mod._mark_google_gemini_cli_active(creds)\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}:google_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 == \"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/cli_commands_mixin.py","hunks":["@@ -947,52 +947,6 @@ def _handle_branch_command(self, cmd_original: str) -> None:\n         _cprint(f\"  Original session: {parent_session_id}\")\n         _cprint(f\"  Branch session:   {new_session_id}\")\n \n-    def _handle_gquota_command(self, cmd_original: str) -> None:\n-        \"\"\"Show Google Gemini Code Assist quota usage for the current OAuth account.\"\"\"\n-        try:\n-            from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials\n-            from agent.google_code_assist import retrieve_user_quota, CodeAssistError\n-        except ImportError as exc:\n-            self._console_print(f\"  [red]Gemini modules unavailable: {exc}[/]\")\n-            return\n-\n-        try:\n-            access_token = [redacted:secret-assignment]\n-        except GoogleOAuthError as exc:\n-            self._console_print(f\"  [yellow]{exc}[/]\")\n-            self._console_print(\"  Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.\")\n-            return\n-\n-        creds = load_credentials()\n-        project_id = (creds.project_id if creds else \"\") or \"\"\n-\n-        try:\n-            buckets = retrieve_user_quota(access_token, project_id=project_id)\n-        except CodeAssistError as exc:\n-            self._console_print(f\"  [red]Quota lookup failed:[/] {exc}\")\n-            return\n-\n-        if not buckets:\n-            self._console_print(\"  [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]\")\n-            return\n-\n-        # Sort for stable display, group by model\n-        buckets.sort(key=lambda b: (b.model_id, b.token_type))\n-        self._console_print()\n-        self._console_print(f\"  [bold]Gemini Code Assist quota[/]  (project: {project_id or '(auto / free-tier)'})\")\n-        self._console_print()\n-        for b in buckets:\n-            pct = max(0.0, min(1.0, b.remaining_fraction))\n-            width = 20\n-            filled = int(round(pct * width))\n-            bar = \"▓\" * filled + \"░\" * (width - filled)\n-            pct_str = f\"{int(pct * 100):3d}%\"\n-            header = b.model_id\n-            if b.token_type:\n-                header += f\" [{b.token_type}]\"\n-            self._console_print(f\"    {header:40s}  {bar}  {pct_str}\")\n-        self._console_print()\n-\n     def _handle_personality_command(self, cmd: str):\n         \"\"\"Handle the /personality command to set predefined personalities.\"\"\"\n         from cli import save_config_value"]},{"file":"hermes_cli/commands.py","hunks":["@@ -128,8 +128,6 @@ class CommandDef:\n     CommandDef(\"codex-runtime\", \"Toggle codex app-server runtime for OpenAI/Codex models\",\n                \"Configuration\", aliases=(\"codex_runtime\",),\n                args_hint=\"[auto|codex_app_server]\"),\n-    CommandDef(\"gquota\", \"Show Google Gemini Code Assist quota usage\", \"Info\",\n-               cli_only=True),\n \n     CommandDef(\"personality\", \"Set a predefined personality\", \"Configuration\",\n                args_hint=\"[name]\"),"]},{"file":"hermes_cli/config.py","hunks":["@@ -169,8 +169,8 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n #   the dashboard. ``config.yaml`` is the supported surface for these.\n #\n # IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate\n-# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,\n-# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The\n+# integration credentials follow that prefix (HERMES_LANGFUSE_PUBLIC_KEY,\n+# HERMES_SPOTIFY_CLIENT_ID, ...). The\n # denylist is name-by-name on purpose so the gate stays narrow and\n # doesn't accidentally break provider setup wizards.\n #","@@ -3082,62 +3082,6 @@ def _ensure_hermes_home_managed(home: Path):\n         \"category\": \"provider\",\n         \"advanced\": True,\n     },\n-    \"HERMES_GEMINI_CLIENT_ID\": {\n-        \"description\": \"Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)\",\n-        \"prompt\": \"Google OAuth client ID (optional — leave empty to use the public default)\",\n-        \"url\": \"https://console.cloud.google.com/apis/credentials\",\n-        \"password\": False,\n-        \"category\": \"provider\",\n-        \"advanced\": True,\n-    },\n-    \"HERMES_GEMINI_CLIENT_SECRET\": {\n-        \"description\": \"Google OAuth client secret for google-gemini-cli (optional)\",\n-        \"prompt\": \"Google 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_GEMINI_PROJECT_ID\": {\n-        \"description\": \"GCP project ID for paid Gemini tiers (free tier auto-provisions)\",\n-        \"prompt\": \"GCP project ID for Gemini OAuth (leave empty for free tier)\",\n-        \"url\": None,\n-        \"password\": False,\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/doctor.py","hunks":["@@ -158,12 +158,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool\n     that direct-key problem into the final blocking summary.\n     \"\"\"\n     normalized = (provider_label or \"\").strip().lower()\n-    if normalized in {\"google / gemini\", \"gemini\"}:\n-        try:\n-            from hermes_cli.auth import get_gemini_oauth_auth_status\n-            return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n-        except Exception:\n-            return False\n     if normalized == \"minimax\":\n         try:\n             from hermes_cli.auth import get_minimax_oauth_auth_status","@@ -1077,7 +1071,6 @@ def run_doctor(args):\n         from hermes_cli.auth import (\n             get_nous_auth_status,\n             get_codex_auth_status,\n-            get_gemini_oauth_auth_status,\n             get_minimax_oauth_auth_status,\n         )\n ","@@ -1105,20 +1098,6 @@ def run_doctor(args):\n                     \"from an existing Codex CLI login)\"\n                 )\n \n-        gemini_status = get_gemini_oauth_auth_status()\n-        if gemini_status.get(\"logged_in\"):\n-            email = gemini_status.get(\"email\") or \"\"\n-            project = gemini_status.get(\"project_id\") or \"\"\n-            pieces = []\n-            if email:\n-                pieces.append(email)\n-            if project:\n-                pieces.append(f\"project={project}\")\n-            suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n-            check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n-        else:\n-            check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n-\n         minimax_status = get_minimax_oauth_auth_status()\n         if minimax_status.get(\"logged_in\"):\n             region = minimax_status.get(\"region\", \"global\")"]},{"file":"hermes_cli/main.py","hunks":["@@ -602,8 +602,6 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:\n     _model_flow_xai_oauth,\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,","@@ -3073,10 +3071,6 @@ def _active_custom_key_from_base_url() -> str:\n         _model_flow_qwen_oauth(config, current_model)\n     elif selected_provider == \"minimax-oauth\":\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\":","@@ -11254,7 +11248,7 @@ def _build_provider_choices() -> list[str]:\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+            \"anthropic\", \"gemini\", \"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\","]},{"file":"hermes_cli/model_setup_flows.py","hunks":["@@ -633,142 +633,6 @@ def _model_flow_minimax_oauth(config, current_model=\"\", args=None):\n     _update_config_for_provider(\"minimax-oauth\", creds[\"base_url\"])\n     print(f\"\\u2713 Using MiniMax model: {selected}\")\n \n-def _model_flow_google_gemini_cli(_config, current_model=\"\"):\n-    \"\"\"Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.\n-\n-    Flow:\n-      1. Show upfront warning about Google's ToS stance (per opencode-gemini-auth).\n-      2. If creds missing, run PKCE browser OAuth via agent.google_oauth.\n-      3. Resolve project context (env -> config -> auto-discover -> free tier).\n-      4. Prompt user to pick a model.\n-      5. Save to ~/.hermes/config.yaml.\n-    \"\"\"\n-    from hermes_cli.auth import (\n-        DEFAULT_GEMINI_CLOUDCODE_BASE_URL,\n-        get_gemini_oauth_auth_status,\n-        resolve_gemini_oauth_runtime_credentials,\n-        _prompt_model_selection,\n-        _save_model_choice,\n-        _update_config_for_provider,\n-    )\n-    from hermes_cli.models import _PROVIDER_MODELS\n-\n-    print()\n-    print(\"⚠  Google considers using the Gemini CLI OAuth client with third-party\")\n-    print(\"   software a policy violation. Some users have reported account\")\n-    print(\"   restrictions. You can use your own API key via 'gemini' provider\")\n-    print(\"   for the lowest-risk experience.\")\n-    print()\n-    try:\n-        proceed = input(\"Continue with OAuth login? [y/N]: \").strip().lower()\n-    except (EOFError, KeyboardInterrupt):\n-        print(\"Cancelled.\")\n-        return\n-    if proceed not in {\"y\", \"yes\"}:\n-        print(\"Cancelled.\")\n-        return\n-\n-    status = get_gemini_oauth_auth_status()\n-    if not status.get(\"logged_in\"):\n-        try:\n-            from agent.google_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-    # Verify creds resolve + trigger project discovery\n-    try:\n-        creds = resolve_gemini_oauth_runtime_credentials(force_refresh=False)\n-        project_id = creds.get(\"project_id\", \"\")\n-        if project_id:\n-            print(f\"  Using GCP project: {project_id}\")\n-        else:\n-            print(\n-                \"  No GCP project configured — free tier will be auto-provisioned on first request.\"\n-            )\n-    except Exception as exc:\n-        print(f\"Failed to resolve Gemini credentials: {exc}\")\n-        return\n-\n-    models = list(_PROVIDER_MODELS.get(\"google-gemini-cli\") or [])\n-    default = current_model or (models[0] if models else \"gemini-3-flash-preview\")\n-    selected = _prompt_model_selection(\n-        models,\n-        current_model=default,\n-        confirm_provider=\"google-gemini-cli\",\n-        confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,\n-    )\n-    if selected:\n-        _save_model_choice(selected)\n-        _update_config_for_provider(\n-            \"google-gemini-cli\", DEFAULT_GEMINI_CLOUDCODE_BASE_URL\n-        )\n-        print(\n-            f\"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)\"\n-        )\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."]},{"file":"hermes_cli/models.py","hunks":["@@ -265,26 +265,6 @@ def _xai_curated_models() -> list[str]:\n         \"gemini-3.5-flash\",\n         \"gemini-3.1-flash-lite-preview\",\n     ],\n-    \"google-gemini-cli\": [\n-        \"gemini-3.1-pro-preview\",\n-        \"gemini-3-pro-preview\",\n-        # Code Assist serves two flash slugs with different access gates\n-        # (gemini-cli models.ts): gemini-3-flash-preview is the preview flash\n-        # that subscription/free-tier OAuth users actually reach, while\n-        # gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users\n-        # aren't stuck with a slug cloudcode-pa 404s for them.\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\",","@@ -1037,8 +1017,6 @@ class ProviderEntry(NamedTuple):\n     ProviderEntry(\"copilot-acp\",    \"GitHub Copilot ACP\",       \"GitHub Copilot ACP (Spawns copilot --acp --stdio)\"),\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)\"),","@@ -1109,7 +1087,7 @@ class ProviderEntry(NamedTuple):\n     \"kimi\":     (\"Kimi / Moonshot\", \"Coding Plan, Moonshot global & China endpoints\", [\"kimi-coding\", \"kimi-coding-cn\"]),\n     \"minimax\":  (\"MiniMax\",         \"Global, OAuth Coding Plan & China endpoints\",     [\"minimax\", \"minimax-oauth\", \"minimax-cn\"]),\n     \"xai\":      (\"xAI Grok\",        \"Direct API or SuperGrok / Premium+ OAuth\",        [\"xai\", \"xai-oauth\"]),\n-    \"google\":   (\"Google Gemini\",   \"AI Studio API or OAuth + Code Assist\",            [\"gemini\", \"google-gemini-cli\"]),\n+    \"google\":   (\"Google Gemini\",   \"Google AI Studio (API key)\",                     [\"gemini\"]),\n     \"openai\":   (\"OpenAI\",          \"Codex CLI or direct OpenAI API\",                  [\"openai-codex\", \"openai-api\"]),\n     \"opencode\": (\"OpenCode\",        \"Zen pay-as-you-go or Go subscription\",            [\"opencode-zen\", \"opencode-go\"]),\n     \"copilot\":  (\"GitHub Copilot\",  \"GitHub token API or copilot --acp process\",       [\"copilot\", \"copilot-acp\"]),","@@ -1230,14 +1208,6 @@ def group_providers(slugs):\n     \"qwen\": \"alibaba\",\n     \"alibaba-cloud\": \"alibaba\",\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\",","@@ -1805,13 +1775,10 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:\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+# would be listed here (tried only as a last resort for bare short-alias\n+# resolution, after every native-vendor catalog, so they never hijack an alias\n+# away from the model's native vendor). None are currently defined.\n+_BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()\n \n \n def _resolve_static_model_alias(","@@ -1863,9 +1830,9 @@ 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+    # Last resort: providers that re-expose other vendors' models. Only reached\n+    # when no native-vendor catalog matched — so `sonnet` resolves to anthropic.\n+    # None are currently defined (_BORROWED_MODEL_PROVIDERS is empty).\n     for provider in _BORROWED_MODEL_PROVIDERS:\n         if provider in current_keys and (matched := _match(provider)):\n             return provider, matched","@@ -2240,32 +2207,6 @@ 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 ","@@ -2296,10 +2237,6 @@ 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/provider_catalog.py","hunks":["@@ -57,7 +57,7 @@\n class ProviderDescriptor:\n     \"\"\"One provider, as seen by every surface (CLI picker + both GUI tabs).\"\"\"\n \n-    slug: str                      # canonical id, e.g. \"google-gemini-cli\"\n+    slug: str                      # canonical id, e.g. \"openai-codex\"\n     label: str                     # human display name\n     description: str               # one-line description\n     auth_type: str                 # api_key | oauth_* | external_process | copilot | aws_sdk"]},{"file":"hermes_cli/providers.py","hunks":["@@ -76,16 +76,6 @@ class HermesOverlay:\n         base_url_override=\"https://portal.qwen.ai/v1\",\n         base_url_env_var=\"HERMES_QWEN_BASE_URL\",\n     ),\n-    \"google-gemini-cli\": HermesOverlay(\n-        transport=\"openai_chat\",\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\",","@@ -315,18 +305,6 @@ class ProviderDef:\n     \"alibaba-coding\": \"alibaba-coding-plan\",\n     \"alibaba_coding_plan\": \"alibaba-coding-plan\",\n \n-    # google-gemini-cli (OAuth + Code Assist)\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\",\n     \"hugging-face\": \"huggingface\","]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -26,8 +26,6 @@\n     resolve_codex_runtime_credentials,\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,","@@ -332,12 +330,6 @@ def _resolve_runtime_from_pool_entry(\n     elif provider == \"qwen-oauth\":\n         api_mode = \"chat_completions\"\n         base_url = base_url or DEFAULT_QWEN_BASE_URL\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","@@ -1618,46 +1610,6 @@ def resolve_runtime_provider(\n                 \"requested_provider\": requested_provider,\n             }\n \n-    if provider == \"google-gemini-cli\":\n-        try:\n-            creds = resolve_gemini_oauth_runtime_credentials()\n-            return {\n-                \"provider\": \"google-gemini-cli\",\n-                \"api_mode\": \"chat_completions\",\n-                \"base_url\": creds.get(\"base_url\", \"\"),\n-                \"api_key\": creds.get(\"api_key\", \"\"),\n-                \"source\": creds.get(\"source\", \"google-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 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":"hermes_cli/tips.py","hunks":["@@ -420,7 +420,6 @@\n     '/platforms shows gateway and messaging-platform connection status right from inside chat.',\n     '/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',\n     '/toolsets lists every available toolset so you know what -t/--toolsets accepts.',\n-    '/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',\n     '/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',\n     '/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',\n     '/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -5640,23 +5640,6 @@ def _claude_code_only_status() -> Dict[str, Any]:\n     return {\"logged_in\": False, \"source\": None}\n \n \n-def _gemini_cli_status() -> Dict[str, Any]:\n-    \"\"\"Status for the google-gemini-cli OAuth provider (Code Assist login).\"\"\"\n-    try:\n-        from hermes_cli import auth as hauth\n-        raw = hauth.get_gemini_oauth_auth_status()\n-    except Exception as e:\n-        return {\"logged_in\": False, \"error\": str(e)}\n-    return {\n-        \"logged_in\": bool(raw.get(\"logged_in\")),\n-        \"source\": raw.get(\"source\") or \"google_oauth\",\n-        \"source_label\": raw.get(\"email\") or raw.get(\"auth_file\") or \"Google Code Assist\",\n-        \"token_preview\": _truncate_token(raw.get(\"api_key\")),\n-        \"expires_at\": None,\n-        \"has_refresh_token\": True,\n-    }\n-\n-\n def _copilot_acp_status() -> Dict[str, Any]:\n     \"\"\"Status for copilot-acp — credentials are owned by the Copilot CLI.\n ","@@ -5736,14 +5719,6 @@ def _copilot_acp_status() -> Dict[str, Any]:\n         \"docs_url\": \"https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth\",\n         \"status_fn\": None,  # dispatched via auth.get_xai_oauth_auth_status\n     },\n-    {\n-        \"id\": \"google-gemini-cli\",\n-        \"name\": \"Google Gemini (OAuth + Code Assist)\",\n-        \"flow\": \"external\",\n-        \"cli_command\": \"hermes auth add google-gemini-cli\",\n-        \"docs_url\": \"https://ai.google.dev/gemini-api/docs\",\n-        \"status_fn\": _gemini_cli_status,\n-    },\n     {\n         \"id\": \"copilot-acp\",\n         \"name\": \"GitHub Copilot (ACP)\","]},{"file":"/dev/null","hunks":["@@ -1,80 +0,0 @@\n-# Gemini OAuth Provider — Implementation Plan\n-\n-## Goal\n-Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys.\n-\n-## Architecture Decision\n-- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta`\n-- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk\n-- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed\n-- Our own OAuth credentials — NOT sharing tokens with Gemini CLI\n-\n-## OAuth Flow\n-- **Type:** Authorization Code + PKCE (S256) — same pattern as clawdbot/pi-mono\n-- **Auth URL:** `https://accounts.google.com/o/oauth2/v2/auth`\n-- **Token URL:** `https://oauth2.googleapis.com/token`\n-- **Redirect:** `http://localhost:8085/oauth2callback` (localhost callback server)\n-- **Fallback:** Manual URL paste for remote/WSL/headless environments\n-- **Scopes:** `https://www.googleapis.com/auth/cloud-platform`, `https://www.googleapis.com/auth/userinfo.email`\n-- **PKCE:** S256 code challenge, 32-byte random verifier\n-\n-## Client ID\n-- Need to register a \"Desktop app\" OAuth client on a Nous Research GCP project\n-- Ship client_id + client_secret in code (Google considers installed app secrets non-confidential)\n-- Alternatively: accept user-provided client_id via env vars as override\n-\n-## Token Lifecycle\n-- Store at `~/.hermes/gemini_oauth.json` (NOT sharing with `~/.gemini/oauth_creds.json`)\n-- Fields: `client_id`, `client_secret`, `refresh_token`, `access_token`, `expires_at`, `email`\n-- File permissions: 0o600\n-- Before each API call: check expiry, refresh if within 5 min of expiration\n-- Refresh: POST to token URL with `grant_type=refresh_token`\n-- File locking for concurrent access (multiple agent sessions)\n-\n-## API Integration\n-- Base URL: `https://generativelanguage.googleapis.com/v1beta`\n-- Auth: native Gemini API authentication handled by the provider adapter\n-- api_mode: `chat_completions` (standard facade over native transport)\n-- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc.\n-\n-## Files to Create/Modify\n-\n-### New files\n-1. `agent/google_oauth.py` — OAuth flow (PKCE, localhost server, token exchange, refresh)\n-   - `start_oauth_flow()` — opens browser, starts callback server\n-   - `exchange_code()` — code → tokens\n-   - `refresh_access_token()` — refresh flow\n-   - `load_credentials()` / `save_credentials()` — file I/O with locking\n-   - `get_valid_access_token()` — check expiry, refresh if needed\n-   - ~200 lines\n-\n-### Existing files to modify\n-2. `hermes_cli/auth.py` — Add ProviderConfig for \"gemini\" with auth_type=\"oauth_google\"\n-3. `hermes_cli/models.py` — Add Gemini model catalog\n-4. `hermes_cli/runtime_provider.py` — Add gemini branch (read OAuth token, build OpenAI client)\n-5. `hermes_cli/main.py` — Add `_model_flow_gemini()`, add to provider choices\n-6. `hermes_cli/setup.py` — Add gemini auth flow (trigger browser OAuth)\n-7. `run_agent.py` — Token refresh before API calls (like Copilot pattern)\n-8. `agent/auxiliary_client.py` — Add gemini to aux resolution chain\n-9. `agent/model_metadata.py` — Add Gemini model context lengths\n-\n-### Tests\n-10. `tests/agent/test_google_oauth.py` — OAuth flow unit tests\n-11. `tests/test_api_key_providers.py` — Add gemini provider test\n-\n-### Docs\n-12. `website/docs/getting-started/quickstart.md` — Add gemini to provider table\n-13. `website/docs/user-guide/configuration.md` — Gemini setup section\n-14. `website/docs/reference/environment-variables.md` — New env vars\n-\n-## Estimated scope\n-~400 lines new code, ~150 lines modifications, ~100 lines tests, ~50 lines docs = ~700 lines total\n-\n-## Prerequisites\n-- Nous Research GCP project with Desktop OAuth client registered\n-- OR: accept user-provided client_id via HERMES_GEMINI_CLIENT_ID env var\n-\n-## Reference implementations\n-- clawdbot: `extensions/google/oauth.flow.ts` (PKCE + localhost server)\n-- pi-mono: `packages/ai/src/utils/oauth/google-gemini-cli.ts` (same flow)\n-- hermes-agent Copilot OAuth: `hermes_cli/main.py` `_copilot_device_flow()` (different flow type but same lifecycle pattern)"]},{"file":"plugins/model-providers/gemini/__init__.py","hunks":["@@ -1,11 +1,9 @@\n \"\"\"Google Gemini provider profiles.\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\n+Reports api_mode=\"chat_completions\" but uses a custom native client\n+that bypasses the standard OpenAI transport. The profile captures auth\n and endpoint metadata for auth.py / runtime_provider.py migration, and\n carries the thinking_config translation hook so the transport's profile\n path produces the same extra_body shape the legacy flag path did.","@@ -60,31 +58,4 @@ def build_extra_body(\n     default_aux_model=\"gemini-3.5-flash\",\n )\n \n-google_gemini_cli = GeminiProfile(\n-    name=\"google-gemini-cli\",\n-    aliases=(\"gemini-cli\", \"gemini-oauth\"),\n-    api_mode=\"chat_completions\",\n-    env_vars=(),  # OAuth — no API key\n-    base_url=\"cloudcode-pa://google\",  # Cloud Code Assist internal scheme\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":"run_agent.py","hunks":["@@ -273,7 +273,7 @@ def _pool_may_recover_from_rate_limit(\n         return False\n     # CloudCode / Gemini CLI quotas are account-wide — all pool entries share\n     # the same throttle window, so rotation can't recover.  Prefer fallback.\n-    if provider == \"google-gemini-cli\" or str(base_url or \"\").startswith(\"cloudcode-pa://\"):\n+    if str(base_url or \"\").startswith(\"cloudcode-pa://\"):\n         return False\n     return len(pool.entries()) > 1\n ","@@ -4093,8 +4093,7 @@ def _credential_pool_may_recover_rate_limit(self) -> bool:\n         if pool is None:\n             return False\n         if (\n-            self.provider == \"google-gemini-cli\"\n-            or str(getattr(self, \"base_url\", \"\")).startswith(\"cloudcode-pa://\")\n+            str(getattr(self, \"base_url\", \"\")).startswith(\"cloudcode-pa://\")\n         ):\n             # CloudCode/Gemini quota windows are usually account-level throttles.\n             # Prefer the configured fallback immediately instead of waiting out"]},{"file":"skills/autonomous-ai-agents/hermes-agent/SKILL.md","hunks":["@@ -336,7 +336,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer\n /commands [page]     Browse all commands (gateway)\n /usage               Token usage\n /insights [days]     Usage analytics\n-/gquota              Show Google Gemini Code Assist quota usage (CLI)\n /status              Session info (gateway)\n /profile             Active profile info\n /debug               Upload debug report (system info + logs) and get shareable links"]},{"file":"/dev/null","hunks":["@@ -1,405 +0,0 @@\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":"/dev/null","hunks":["@@ -1,1228 +0,0 @@\n-\"\"\"Tests for the google-gemini-cli OAuth + Code Assist inference provider.\n-\n-Covers:\n-- agent/google_oauth.py — PKCE, credential I/O with packed refresh format,\n-  token refresh dedup, invalid_grant handling, headless paste fallback\n-- agent/google_code_assist.py — project discovery, VPC-SC fallback, onboarding\n-  with LRO polling, quota retrieval\n-- agent/gemini_cloudcode_adapter.py — OpenAI↔Gemini translation, request\n-  envelope wrapping, response unwrapping, tool calls bidirectional, streaming\n-- Provider registration — registry entry, aliases, runtime dispatch, auth\n-  status, _OAUTH_CAPABLE_PROVIDERS regression guard\n-\"\"\"\n-from __future__ import annotations\n-\n-import base64\n-import hashlib\n-import json\n-import stat\n-import time\n-from pathlib import Path\n-\n-import pytest\n-\n-\n-# =============================================================================\n-# Fixtures\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_GEMINI_CLIENT_ID\",\n-        \"HERMES_GEMINI_CLIENT_SECRET\",\n-        \"HERMES_GEMINI_PROJECT_ID\",\n-        \"GOOGLE_CLOUD_PROJECT\",\n-        \"GOOGLE_CLOUD_PROJECT_ID\",\n-        \"SSH_CONNECTION\",\n-        \"SSH_CLIENT\",\n-        \"SSH_TTY\",\n-        \"HERMES_HEADLESS\",\n-    ):\n-        monkeypatch.delenv(key, raising=False)\n-    return home\n-\n-\n-# =============================================================================\n-# google_oauth.py — PKCE + packed refresh format\n-# =============================================================================\n-\n-class TestPkce:\n-    def test_verifier_and_challenge_s256_roundtrip(self):\n-        from agent.google_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-\n-class TestRefreshParts:\n-    def test_parse_bare_token(self):\n-        from agent.google_oauth import RefreshParts\n-\n-        p = RefreshParts.parse(\"abc-token\")\n-        assert p.refresh_token == \"abc-token\"\n-        assert p.project_id == \"\"\n-        assert p.managed_project_id == \"\"\n-\n-    def test_parse_packed(self):\n-        from agent.google_oauth import RefreshParts\n-\n-        p = RefreshParts.parse(\"rt|proj-123|mgr-456\")\n-        assert p.refresh_token == \"rt\"\n-        assert p.project_id == \"proj-123\"\n-        assert p.managed_project_id == \"mgr-456\"\n-\n-    def test_format_bare_token(self):\n-        from agent.google_oauth import RefreshParts\n-\n-        assert RefreshParts(refresh_token=\"rt\").format() == \"rt\"\n-\n-    def test_format_with_project(self):\n-        from agent.google_oauth import RefreshParts\n-\n-        packed = RefreshParts(\n-            refresh_token=\"rt\", project_id=\"p1\", managed_project_id=\"m1\",\n-        ).format()\n-        assert packed == \"rt|p1|m1\"\n-        # Roundtrip\n-        parsed = RefreshParts.parse(packed)\n-        assert parsed.refresh_token == \"rt\"\n-        assert parsed.project_id == \"p1\"\n-        assert parsed.managed_project_id == \"m1\"\n-\n-    def test_format_empty_refresh_token_returns_empty(self):\n-        from agent.google_oauth import RefreshParts\n-\n-        assert RefreshParts(refresh_token=\"\").format() == \"\"\n-\n-\n-class TestClientCredResolution:\n-    def test_env_override(self, monkeypatch):\n-        from agent.google_oauth import _get_client_id\n-\n-        monkeypatch.setenv(\"HERMES_GEMINI_CLIENT_ID\", \"custom-id.apps.googleusercontent.com\")\n-        assert _get_client_id() == \"custom-id.apps.googleusercontent.com\"\n-\n-    def test_shipped_default_used_when_no_env(self):\n-        \"\"\"Out of the box, the public gemini-cli desktop client is used.\"\"\"\n-        from agent.google_oauth import _get_client_id, _DEFAULT_CLIENT_ID\n-\n-        # Confirmed PUBLIC: baked into Google's open-source gemini-cli\n-        assert _DEFAULT_CLIENT_ID.endswith(\".apps.googleusercontent.com\")\n-        assert _DEFAULT_CLIENT_ID.startswith(\"681255809395-\")\n-        assert _get_client_id() == _DEFAULT_CLIENT_ID\n-\n-    def test_shipped_default_secret_present(self):\n-        from agent.google_oauth import _DEFAULT_CLIENT_SECRET, _get_client_secret\n-\n-        assert _DEFAULT_CLIENT_SECRET.startswith(\"GOCSPX-\")\n-        assert len(_DEFAULT_CLIENT_SECRET) >= 20\n-        assert _get_client_secret() == _DEFAULT_CLIENT_SECRET\n-\n-    def test_falls_back_to_scrape_when_defaults_wiped(self, tmp_path, monkeypatch):\n-        \"\"\"Forks that wipe the shipped defaults should still work with gemini-cli.\"\"\"\n-        from agent import google_oauth\n-\n-        monkeypatch.setattr(google_oauth, \"_DEFAULT_CLIENT_ID\", \"\")\n-        monkeypatch.setattr(google_oauth, \"_DEFAULT_CLIENT_SECRET\", \"\")\n-\n-        fake_bin = tmp_path / \"bin\" / \"gemini\"\n-        fake_bin.parent.mkdir(parents=True)\n-        fake_bin.write_text(\"#!/bin/sh\\n\")\n-        oauth_dir = tmp_path / \"node_modules\" / \"@google\" / \"gemini-cli-core\" / \"dist\" / \"src\" / \"code_assist\"\n-        oauth_dir.mkdir(parents=True)\n-        (oauth_dir / \"oauth2.js\").write_text(\n-            'const OAUTH_CLIENT_ID = \"99999-fakescrapedxyz.apps.googleusercontent.com\";\\n'\n-            'const OAUTH_CLIENT_SECRET = \"[redacted:secret-assignment]\";\\n'\n-        )\n-\n-        monkeypatch.setattr(\"shutil.which\", lambda _: str(fake_bin))\n-        google_oauth._scraped_creds_cache.clear()\n-\n-        assert google_oauth._get_client_id().startswith(\"99999-\")\n-\n-    def test_missing_everything_raises_with_install_hint(self, monkeypatch):\n-        \"\"\"When env + defaults + scrape all fail, raise with install instructions.\"\"\"\n-        from agent import google_oauth\n-\n-        monkeypatch.setattr(google_oauth, \"_DEFAULT_CLIENT_ID\", \"\")\n-        monkeypatch.setattr(google_oauth, \"_DEFAULT_CLIENT_SECRET\", \"\")\n-        google_oauth._scraped_creds_cache.clear()\n-        monkeypatch.setattr(\"shutil.which\", lambda _: None)\n-\n-        with pytest.raises(google_oauth.GoogleOAuthError) as exc_info:\n-            google_oauth._require_client_id()\n-        assert exc_info.value.code == \"google_oauth_client_id_missing\"\n-\n-    def test_locate_gemini_cli_oauth_js_when_absent(self, monkeypatch):\n-        from agent import google_oauth\n-\n-        monkeypatch.setattr(\"shutil.which\", lambda _: None)\n-        assert google_oauth._locate_gemini_cli_oauth_js() is None\n-\n-    def test_scrape_client_credentials_parses_id_and_secret(self, tmp_path, monkeypatch):\n-        from agent import google_oauth\n-\n-        # Create a fake gemini binary and oauth2.js\n-        fake_gemini_bin = tmp_path / \"bin\" / \"gemini\"\n-        fake_gemini_bin.parent.mkdir(parents=True)\n-        fake_gemini_bin.write_text(\"#!/bin/sh\\necho gemini\\n\")\n-\n-        oauth_js_dir = tmp_path / \"node_modules\" / \"@google\" / \"gemini-cli-core\" / \"dist\" / \"src\" / \"code_assist\"\n-        oauth_js_dir.mkdir(parents=True)\n-        oauth_js = oauth_js_dir / \"oauth2.js\"\n-        # Synthesize a harmless test fingerprint (valid shape, obvious test values)\n-        oauth_js.write_text(\n-            'const OAUTH_CLIENT_ID = \"12345678-testfakenotrealxyz.apps.googleusercontent.com\";\\n'\n-            'const OAUTH_CLIENT_SECRET = \"[redacted:secret-assignment]\";\\n'\n-        )\n-\n-        monkeypatch.setattr(\"shutil.which\", lambda _: str(fake_gemini_bin))\n-        google_oauth._scraped_creds_cache.clear()\n-\n-        cid, cs = google_oauth._scrape_client_credentials()\n-        assert cid == \"12345678-testfakenotrealxyz.apps.googleusercontent.com\"\n-        assert cs.startswith(\"GOCSPX-\")\n-\n-\n-class TestCredentialIo:\n-    def _make(self):\n-        from agent.google_oauth import GoogleCredentials\n-\n-        return GoogleCredentials(\n-            access_token=\"at-1\",\n-            refresh_token=\"rt-1\",\n-            expires_ms=int((time.time() + 3600) * 1000),\n-            email=\"[redacted:email]\",\n-            project_id=\"proj-abc\",\n-        )\n-\n-    def test_save_and_load_packed_refresh(self):\n-        from agent.google_oauth import load_credentials, save_credentials\n-\n-        creds = self._make()\n-        save_credentials(creds)\n-        loaded = load_credentials()\n-        assert loaded is not None\n-        assert loaded.refresh_token == \"rt-1\"\n-        assert loaded.project_id == \"proj-abc\"\n-\n-    def test_save_uses_0600_permissions(self):\n-        from agent.google_oauth import _credentials_path, save_credentials\n-\n-        save_credentials(self._make())\n-        mode = stat.S_IMODE(_credentials_path().stat().st_mode)\n-        assert mode == 0o600\n-\n-    def test_disk_format_is_packed(self):\n-        from agent.google_oauth import _credentials_path, save_credentials\n-\n-        save_credentials(self._make())\n-        data = json.loads(_credentials_path().read_text())\n-        # The refresh field on disk is the packed string, not a dict\n-        assert data[\"refresh\"] == \"rt-1|proj-abc|\"\n-\n-    def test_update_project_ids(self):\n-        from agent.google_oauth import (\n-            load_credentials, save_credentials, update_project_ids,\n-        )\n-        from agent.google_oauth import GoogleCredentials\n-\n-        save_credentials(GoogleCredentials(\n-            access_token=\"at\", refresh_token=\"rt\",\n-            expires_ms=int((time.time() + 3600) * 1000),\n-        ))\n-        update_project_ids(project_id=\"new-proj\", managed_project_id=\"mgr-xyz\")\n-\n-        loaded = load_credentials()\n-        assert loaded.project_id == \"new-proj\"\n-        assert loaded.managed_project_id == \"mgr-xyz\"\n-\n-\n-class TestAccessTokenExpired:\n-    def test_fresh_token_not_expired(self):\n-        from agent.google_oauth import GoogleCredentials\n-\n-        creds = GoogleCredentials(\n-            access_token=\"at\", refresh_token=\"rt\",\n-            expires_ms=int((time.time() + 3600) * 1000),\n-        )\n-        assert creds.access_token_expired() is False\n-\n-    def test_near_expiry_considered_expired(self):\n-        \"\"\"60s skew — a token with 30s left is considered expired.\"\"\"\n-        from agent.google_oauth import GoogleCredentials\n-\n-        creds = GoogleCredentials(\n-            access_token=\"at\", refresh_token=\"rt\",\n-            expires_ms=int((time.time() + 30) * 1000),\n-        )\n-        assert creds.access_token_expired() is True\n-\n-    def test_no_token_is_expired(self):\n-        from agent.google_oauth import GoogleCredentials\n-\n-        creds = GoogleCredentials(\n-            access_token=\"\", refresh_token=\"rt\", expires_ms=999999999,\n-        )\n-        assert creds.access_token_expired() is True\n-\n-\n-class TestGetValidAccessToken:\n-    def _save(self, **over):\n-        from agent.google_oauth import GoogleCredentials, save_credentials\n-\n-        defaults = {\n-            \"access_token\": \"at\",\n-            \"refresh_token\": \"rt\",\n-            \"expires_ms\": int((time.time() + 3600) * 1000),\n-        }\n-        defaults.update(over)\n-        save_credentials(GoogleCredentials(**defaults))\n-\n-    def test_returns_cached_when_fresh(self):\n-        from agent.google_oauth import get_valid_access_token\n-\n-        self._save(access_token=\"[redacted:secret-assignment]\")\n-        assert get_valid_access_token() == \"cached-token\"\n-\n-    def test_refreshes_when_near_expiry(self, monkeypatch):\n-        from agent import google_oauth\n-\n-        self._save(expires_ms=int((time.time() + 30) * 1000))\n-        monkeypatch.setattr(\n-            google_oauth, \"_post_form\",\n-            lambda *a, **kw: {\"access_token\": \"refreshed\", \"expires_in\": 3600},\n-        )\n-        assert google_oauth.get_valid_access_token() == \"refreshed\"\n-\n-    def test_invalid_grant_clears_credentials(self, monkeypatch):\n-        from agent import google_oauth\n-\n-        self._save(expires_ms=int((time.time() - 10) * 1000))\n-\n-        def boom(*a, **kw):\n-            raise google_oauth.GoogleOAuthError(\n-                \"invalid_grant\", code=\"google_oauth_invalid_grant\",\n-            )\n-\n-        monkeypatch.setattr(google_oauth, \"_post_form\", boom)\n-\n-        with pytest.raises(google_oauth.GoogleOAuthError) as exc_info:\n-            google_oauth.get_valid_access_token()\n-        assert exc_info.value.code == \"google_oauth_invalid_grant\"\n-        # Credentials should be wiped\n-        assert google_oauth.load_credentials() is None\n-\n-    def test_preserves_refresh_when_google_omits(self, monkeypatch):\n-        from agent import google_oauth\n-\n-        self._save(expires_ms=int((time.time() + 30) * 1000), refresh_token=\"[redacted:secret-assignment]\")\n-        monkeypatch.setattr(\n-            google_oauth, \"_post_form\",\n-            lambda *a, **kw: {\"access_token\": \"new\", \"expires_in\": 3600},\n-        )\n-        google_oauth.get_valid_access_token()\n-        assert google_oauth.load_credentials().refresh_token == \"original-rt\"\n-\n-\n-class TestProjectIdResolution:\n-    @pytest.mark.parametrize(\"env_var\", [\n-        \"HERMES_GEMINI_PROJECT_ID\",\n-        \"GOOGLE_CLOUD_PROJECT\",\n-        \"GOOGLE_CLOUD_PROJECT_ID\",\n-    ])\n-    def test_env_vars_checked(self, monkeypatch, env_var):\n-        from agent.google_oauth import resolve_project_id_from_env\n-\n-        monkeypatch.setenv(env_var, \"test-proj\")\n-        assert resolve_project_id_from_env() == \"test-proj\"\n-\n-    def test_priority_order(self, monkeypatch):\n-        from agent.google_oauth import resolve_project_id_from_env\n-\n-        monkeypatch.setenv(\"GOOGLE_CLOUD_PROJECT\", \"lower-priority\")\n-        monkeypatch.setenv(\"HERMES_GEMINI_PROJECT_ID\", \"higher-priority\")\n-        assert resolve_project_id_from_env() == \"higher-priority\"\n-\n-    def test_no_env_returns_empty(self):\n-        from agent.google_oauth import resolve_project_id_from_env\n-\n-        assert resolve_project_id_from_env() == \"\"\n-\n-\n-class TestHeadlessDetection:\n-    def test_detects_ssh(self, monkeypatch):\n-        from agent.google_oauth import _is_headless\n-\n-        monkeypatch.setenv(\"SSH_CONNECTION\", \"1.2.3.4 22 5.6.7.8 9876\")\n-        assert _is_headless() is True\n-\n-    def test_detects_hermes_headless(self, monkeypatch):\n-        from agent.google_oauth import _is_headless\n-\n-        monkeypatch.setenv(\"HERMES_HEADLESS\", \"1\")\n-        assert _is_headless() is True\n-\n-    def test_default_not_headless(self):\n-        from agent.google_oauth import _is_headless\n-\n-        assert _is_headless() is False\n-\n-\n-# =============================================================================\n-# google_code_assist.py — project discovery, onboarding, quota, VPC-SC\n-# =============================================================================\n-\n-class TestCodeAssistVpcScDetection:\n-    def test_detects_vpc_sc_in_json(self):\n-        from agent.google_code_assist import _is_vpc_sc_violation\n-\n-        body = json.dumps({\n-            \"error\": {\n-                \"details\": [{\"reason\": \"SECURITY_POLICY_VIOLATED\"}],\n-                \"message\": \"blocked by policy\",\n-            }\n-        })\n-        assert _is_vpc_sc_violation(body) is True\n-\n-    def test_detects_vpc_sc_in_message(self):\n-        from agent.google_code_assist import _is_vpc_sc_violation\n-\n-        body = '{\"error\": {\"message\": \"SECURITY_POLICY_VIOLATED\"}}'\n-        assert _is_vpc_sc_violation(body) is True\n-\n-    def test_non_vpc_sc_returns_false(self):\n-        from agent.google_code_assist import _is_vpc_sc_violation\n-\n-        assert _is_vpc_sc_violation('{\"error\": {\"message\": \"not found\"}}') is False\n-        assert _is_vpc_sc_violation(\"\") is False\n-\n-\n-class TestLoadCodeAssist:\n-    def test_parses_response(self, monkeypatch):\n-        from agent import google_code_assist\n-\n-        fake = {\n-            \"currentTier\": {\"id\": \"free-tier\"},\n-            \"cloudaicompanionProject\": \"proj-123\",\n-            \"allowedTiers\": [{\"id\": \"free-tier\"}, {\"id\": \"standard-tier\"}],\n-        }\n-        monkeypatch.setattr(google_code_assist, \"_post_json\", lambda *a, **kw: fake)\n-\n-        info = google_code_assist.load_code_assist(\"access-token\")\n-        assert info.current_tier_id == \"free-tier\"\n-        assert info.cloudaicompanion_project == \"proj-123\"\n-        assert \"free-tier\" in info.allowed_tiers\n-        assert \"standard-tier\" in info.allowed_tiers\n-\n-    def test_vpc_sc_forces_standard_tier(self, monkeypatch):\n-        from agent import google_code_assist\n-\n-        def boom(*a, **kw):\n-            raise google_code_assist.CodeAssistError(\n-                \"VPC-SC policy violation\", code=\"code_assist_vpc_sc\",\n-            )\n-\n-        monkeypatch.setattr(google_code_assist, \"_post_json\", boom)\n-\n-        info = google_code_assist.load_code_assist(\"access-token\", project_id=\"corp-proj\")\n-        assert info.current_tier_id == \"standard-tier\"\n-        assert info.cloudaicompanion_project == \"corp-proj\"\n-\n-\n-class TestOnboardUser:\n-    def test_paid_tier_requires_project_id(self):\n-        from agent import google_code_assist\n-\n-        with pytest.raises(google_code_assist.ProjectIdRequiredError):\n-            google_code_assist.onboard_user(\n-                \"at\", tier_id=\"standard-tier\", project_id=\"\",\n-            )\n-\n-    def test_free_tier_no_project_required(self, monkeypatch):\n-        from agent import google_code_assist\n-\n-        monkeypatch.setattr(\n-            google_code_assist, \"_post_json\",\n-            lambda *a, **kw: {\"done\": True, \"response\": {\"cloudaicompanionProject\": \"gen-123\"}},\n-        )\n-        resp = google_code_assist.onboard_user(\"at\", tier_id=\"free-tier\")\n-        assert resp[\"done\"] is True\n-\n-    def test_lro_polling(self, monkeypatch):\n-        \"\"\"Simulate a long-running operation that completes on the second poll.\"\"\"\n-        from agent import google_code_assist\n-\n-        call_count = {\"n\": 0}\n-\n-        def fake_post(url, body, token, **kw):\n-            call_count[\"n\"] += 1\n-            if call_count[\"n\"] == 1:\n-                return {\"name\": \"operations/op-abc\", \"done\": False}\n-            return {\"name\": \"operations/op-abc\", \"done\": True, \"response\": {}}\n-\n-        monkeypatch.setattr(google_code_assist, \"_post_json\", fake_post)\n-        monkeypatch.setattr(google_code_assist.time, \"sleep\", lambda *_: None)\n-\n-        resp = google_code_assist.onboard_user(\n-            \"at\", tier_id=\"free-tier\",\n-        )\n-        assert resp[\"done\"] is True\n-        assert call_count[\"n\"] >= 2\n-\n-\n-class TestRetrieveUserQuota:\n-    def test_parses_buckets(self, monkeypatch):\n-        from agent import google_code_assist\n-\n-        fake = {\n-            \"buckets\": [\n-                {\n-                    \"modelId\": \"gemini-2.5-pro\",\n-                    \"tokenType\": \"input\",\n-                    \"remainingFraction\": 0.75,\n-                    \"resetTime\": \"2026-04-17T00:00:00Z\",\n-                },\n-                {\n-                    \"modelId\": \"gemini-2.5-flash\",\n-                    \"remainingFraction\": 0.9,\n-                },\n-            ]\n-        }\n-        monkeypatch.setattr(google_code_assist, \"_post_json\", lambda *a, **kw: fake)\n-\n-        buckets = google_code_assist.retrieve_user_quota(\"at\", project_id=\"p1\")\n-        assert len(buckets) == 2\n-        assert buckets[0].model_id == \"gemini-2.5-pro\"\n-        assert buckets[0].remaining_fraction == 0.75\n-        assert buckets[1].remaining_fraction == 0.9\n-\n-\n-class TestResolveProjectContext:\n-    def test_configured_shortcircuits(self, monkeypatch):\n-        from agent.google_code_assist import resolve_project_context\n-\n-        # Should NOT call loadCodeAssist when configured_project_id is set\n-        def should_not_be_called(*a, **kw):\n-            raise AssertionError(\"should short-circuit\")\n-\n-        monkeypatch.setattr(\n-            \"agent.google_code_assist._post_json\", should_not_be_called,\n-        )\n-        ctx = resolve_project_context(\"at\", configured_project_id=\"proj-abc\")\n-        assert ctx.project_id == \"proj-abc\"\n-        assert ctx.source == \"config\"\n-\n-    def test_env_shortcircuits(self, monkeypatch):\n-        from agent.google_code_assist import resolve_project_context\n-\n-        monkeypatch.setattr(\n-            \"agent.google_code_assist._post_json\",\n-            lambda *a, **kw: (_ for _ in ()).throw(AssertionError(\"nope\")),\n-        )\n-        ctx = resolve_project_context(\"at\", env_project_id=\"env-proj\")\n-        assert ctx.project_id == \"env-proj\"\n-        assert ctx.source == \"env\"\n-\n-    def test_discovers_via_load_code_assist(self, monkeypatch):\n-        from agent import google_code_assist\n-\n-        monkeypatch.setattr(\n-            google_code_assist, \"_post_json\",\n-            lambda *a, **kw: {\n-                \"currentTier\": {\"id\": \"free-tier\"},\n-                \"cloudaicompanionProject\": \"discovered-proj\",\n-            },\n-        )\n-        ctx = google_code_assist.resolve_project_context(\"at\")\n-        assert ctx.project_id == \"discovered-proj\"\n-        assert ctx.tier_id == \"free-tier\"\n-        assert ctx.source == \"discovered\"\n-\n-\n-# =============================================================================\n-# gemini_cloudcode_adapter.py — request/response translation\n-# =============================================================================\n-\n-class TestBuildGeminiRequest:\n-    def test_user_assistant_messages(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(messages=[\n-            {\"role\": \"user\", \"content\": \"hi\"},\n-            {\"role\": \"assistant\", \"content\": \"hello\"},\n-        ])\n-        assert req[\"contents\"][0] == {\n-            \"role\": \"user\", \"parts\": [{\"text\": \"hi\"}],\n-        }\n-        assert req[\"contents\"][1] == {\n-            \"role\": \"model\", \"parts\": [{\"text\": \"hello\"}],\n-        }\n-\n-    def test_system_instruction_separated(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(messages=[\n-            {\"role\": \"system\", \"content\": \"You are helpful\"},\n-            {\"role\": \"user\", \"content\": \"hi\"},\n-        ])\n-        assert req[\"systemInstruction\"][\"parts\"][0][\"text\"] == \"You are helpful\"\n-        # System should NOT appear in contents\n-        assert all(c[\"role\"] != \"system\" for c in req[\"contents\"])\n-\n-    def test_multiple_system_messages_joined(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(messages=[\n-            {\"role\": \"system\", \"content\": \"A\"},\n-            {\"role\": \"system\", \"content\": \"B\"},\n-            {\"role\": \"user\", \"content\": \"hi\"},\n-        ])\n-        assert \"A\\nB\" in req[\"systemInstruction\"][\"parts\"][0][\"text\"]\n-\n-    def test_tool_call_translation(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(messages=[\n-            {\"role\": \"user\", \"content\": \"what's the weather?\"},\n-            {\n-                \"role\": \"assistant\",\n-                \"content\": None,\n-                \"tool_calls\": [{\n-                    \"id\": \"call_1\",\n-                    \"type\": \"function\",\n-                    \"function\": {\"name\": \"get_weather\", \"arguments\": '{\"city\": \"SF\"}'},\n-                }],\n-            },\n-        ])\n-        # Assistant turn should have a functionCall part\n-        model_turn = req[\"contents\"][1]\n-        assert model_turn[\"role\"] == \"model\"\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\n-\n-        req = build_gemini_request(messages=[\n-            {\"role\": \"user\", \"content\": \"q\"},\n-            {\"role\": \"assistant\", \"tool_calls\": [{\n-                \"id\": \"c1\", \"type\": \"function\",\n-                \"function\": {\"name\": \"get_weather\", \"arguments\": \"{}\"},\n-            }]},\n-            {\n-                \"role\": \"tool\",\n-                \"name\": \"get_weather\",\n-                \"tool_call_id\": \"c1\",\n-                \"content\": '{\"temp\": 72}',\n-            },\n-        ])\n-        # Last content turn should carry functionResponse\n-        last = req[\"contents\"][-1]\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\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            tools=[\n-                {\"type\": \"function\", \"function\": {\n-                    \"name\": \"fn1\", \"description\": \"foo\",\n-                    \"parameters\": {\"type\": \"object\"},\n-                }},\n-            ],\n-        )\n-        decls = req[\"tools\"][0][\"functionDeclarations\"]\n-        assert decls[0][\"name\"] == \"fn1\"\n-        assert decls[0][\"description\"] == \"foo\"\n-        assert decls[0][\"parameters\"] == {\"type\": \"object\"}\n-\n-    def test_tools_strip_json_schema_only_fields_from_parameters(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            tools=[\n-                {\"type\": \"function\", \"function\": {\n-                    \"name\": \"fn1\",\n-                    \"description\": \"foo\",\n-                    \"parameters\": {\n-                        \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n-                        \"type\": \"object\",\n-                        \"additionalProperties\": False,\n-                        \"properties\": {\n-                            \"city\": {\n-                                \"type\": \"string\",\n-                                \"$schema\": \"ignored\",\n-                                \"description\": \"City name\",\n-                                \"additionalProperties\": False,\n-                            }\n-                        },\n-                        \"required\": [\"city\"],\n-                    },\n-                }},\n-            ],\n-        )\n-        params = req[\"tools\"][0][\"functionDeclarations\"][0][\"parameters\"]\n-        assert \"$schema\" not in params\n-        assert \"additionalProperties\" not in params\n-        assert params[\"type\"] == \"object\"\n-        assert params[\"required\"] == [\"city\"]\n-        assert params[\"properties\"][\"city\"] == {\n-            \"type\": \"string\",\n-            \"description\": \"City name\",\n-        }\n-\n-    def test_tool_choice_auto(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            tool_choice=\"auto\",\n-        )\n-        assert req[\"toolConfig\"][\"functionCallingConfig\"][\"mode\"] == \"AUTO\"\n-\n-    def test_tool_choice_required(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            tool_choice=\"required\",\n-        )\n-        assert req[\"toolConfig\"][\"functionCallingConfig\"][\"mode\"] == \"ANY\"\n-\n-    def test_tool_choice_specific_function(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            tool_choice={\"type\": \"function\", \"function\": {\"name\": \"my_fn\"}},\n-        )\n-        cfg = req[\"toolConfig\"][\"functionCallingConfig\"]\n-        assert cfg[\"mode\"] == \"ANY\"\n-        assert cfg[\"allowedFunctionNames\"] == [\"my_fn\"]\n-\n-    def test_generation_config_params(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            temperature=0.7,\n-            max_tokens=512,\n-            top_p=0.9,\n-            stop=[\"###\", \"END\"],\n-        )\n-        gc = req[\"generationConfig\"]\n-        assert gc[\"temperature\"] == 0.7\n-        assert gc[\"maxOutputTokens\"] == 512\n-        assert gc[\"topP\"] == 0.9\n-        assert gc[\"stopSequences\"] == [\"###\", \"END\"]\n-\n-    def test_thinking_config_normalization(self):\n-        from agent.gemini_cloudcode_adapter import build_gemini_request\n-\n-        req = build_gemini_request(\n-            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n-            thinking_config={\"thinking_budget\": 1024, \"include_thoughts\": True},\n-        )\n-        tc = req[\"generationConfig\"][\"thinkingConfig\"]\n-        assert tc[\"thinkingBudget\"] == 1024\n-        assert tc[\"includeThoughts\"] is True\n-\n-\n-class TestWrapCodeAssistRequest:\n-    def test_envelope_shape(self):\n-        from agent.gemini_cloudcode_adapter import wrap_code_assist_request\n-\n-        inner = {\"contents\": [], \"generationConfig\": {}}\n-        wrapped = wrap_code_assist_request(\n-            project_id=\"p1\", model=\"gemini-2.5-pro\", inner_request=inner,\n-        )\n-        assert wrapped[\"project\"] == \"p1\"\n-        assert wrapped[\"model\"] == \"gemini-2.5-pro\"\n-        assert wrapped[\"request\"] is inner\n-        assert \"user_prompt_id\" in wrapped\n-        assert len(wrapped[\"user_prompt_id\"]) > 10\n-\n-\n-class TestTranslateGeminiResponse:\n-    def test_text_response(self):\n-        from agent.gemini_cloudcode_adapter import _translate_gemini_response\n-\n-        resp = {\n-            \"response\": {\n-                \"candidates\": [{\n-                    \"content\": {\"parts\": [{\"text\": \"hello world\"}]},\n-                    \"finishReason\": \"STOP\",\n-                }],\n-                \"usageMetadata\": {\n-                    \"promptTokenCount\": 10,\n-                    \"candidatesTokenCount\": 5,\n-                    \"totalTokenCount\": 15,\n-                },\n-            }\n-        }\n-        result = _translate_gemini_response(resp, model=\"gemini-2.5-flash\")\n-        assert result.choices[0].message.content == \"hello world\"\n-        assert result.choices[0].message.tool_calls is None\n-        assert result.choices[0].finish_reason == \"stop\"\n-        assert result.usage.prompt_tokens == 10\n-        assert result.usage.completion_tokens == 5\n-        assert result.usage.total_tokens == 15\n-\n-    def test_function_call_response(self):\n-        from agent.gemini_cloudcode_adapter import _translate_gemini_response\n-\n-        resp = {\n-            \"response\": {\n-                \"candidates\": [{\n-                    \"content\": {\"parts\": [{\n-                        \"functionCall\": {\"name\": \"lookup\", \"args\": {\"q\": \"weather\"}, \"id\": \"provider-call-1\"},\n-                    }]},\n-                    \"finishReason\": \"STOP\",\n-                }],\n-            }\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\"\n-\n-    def test_thought_parts_go_to_reasoning(self):\n-        from agent.gemini_cloudcode_adapter import _translate_gemini_response\n-\n-        resp = {\n-            \"response\": {\n-                \"candidates\": [{\n-                    \"content\": {\"parts\": [\n-                        {\"thought\": True, \"text\": \"let me think\"},\n-                        {\"text\": \"final answer\"},\n-                    ]},\n-                }],\n-            }\n-        }\n-        result = _translate_gemini_response(resp, model=\"gemini-2.5-flash\")\n-        assert result.choices[0].message.content == \"final answer\"\n-        assert result.choices[0].message.reasoning == \"let me think\"\n-\n-    def test_unwraps_direct_format(self):\n-        \"\"\"If response is already at top level (no 'response' wrapper), still parse.\"\"\"\n-        from agent.gemini_cloudcode_adapter import _translate_gemini_response\n-\n-        resp = {\n-            \"candidates\": [{\n-                \"content\": {\"parts\": [{\"text\": \"hi\"}]},\n-                \"finishReason\": \"STOP\",\n-            }],\n-        }\n-        result = _translate_gemini_response(resp, model=\"gemini-2.5-flash\")\n-        assert result.choices[0].message.content == \"hi\"\n-\n-    def test_empty_candidates(self):\n-        from agent.gemini_cloudcode_adapter import _translate_gemini_response\n-\n-        result = _translate_gemini_response({\"response\": {\"candidates\": []}}, model=\"gemini-2.5-flash\")\n-        assert result.choices[0].message.content == \"\"\n-        assert result.choices[0].finish_reason == \"stop\"\n-\n-    def test_finish_reason_mapping(self):\n-        from agent.gemini_cloudcode_adapter import _map_gemini_finish_reason\n-\n-        assert _map_gemini_finish_reason(\"STOP\") == \"stop\"\n-        assert _map_gemini_finish_reason(\"MAX_TOKENS\") == \"length\"\n-        assert _map_gemini_finish_reason(\"SAFETY\") == \"content_filter\"\n-        assert _map_gemini_finish_reason(\"RECITATION\") == \"content_filter\"\n-\n-\n-class TestTranslateStreamEvent:\n-    def test_parallel_calls_to_same_tool_get_unique_indices(self):\n-        \"\"\"Gemini may emit several functionCall parts with the same name in a\n-        single turn (e.g. parallel file reads). Each must get its own OpenAI\n-        ``index`` — otherwise downstream aggregators collapse them into one.\n-        \"\"\"\n-        from agent.gemini_cloudcode_adapter import _translate_stream_event\n-\n-        event = {\n-            \"response\": {\n-                \"candidates\": [{\n-                    \"content\": {\"parts\": [\n-                        {\"functionCall\": {\"name\": \"read_file\", \"args\": {\"path\": \"a\"}}},\n-                        {\"functionCall\": {\"name\": \"read_file\", \"args\": {\"path\": \"b\"}}},\n-                        {\"functionCall\": {\"name\": \"read_file\", \"args\": {\"path\": \"c\"}}},\n-                    ]},\n-                }],\n-            }\n-        }\n-        counter = [0]\n-        chunks = _translate_stream_event(event, model=\"gemini-2.5-flash\",\n-                                         tool_call_counter=counter)\n-        indices = [c.choices[0].delta.tool_calls[0].index for c in chunks]\n-        assert indices == [0, 1, 2]\n-        assert counter[0] == 3\n-\n-    def test_counter_persists_across_events(self):\n-        \"\"\"Index assignment must continue across SSE events in the same stream.\"\"\"\n-        from agent.gemini_cloudcode_adapter import _translate_stream_event\n-\n-        def _event(name):\n-            return {\"response\": {\"candidates\": [{\n-                \"content\": {\"parts\": [{\"functionCall\": {\"name\": name, \"args\": {}}}]},\n-            }]}}\n-\n-        counter = [0]\n-        chunks_a = _translate_stream_event(_event(\"foo\"), model=\"m\", tool_call_counter=counter)\n-        chunks_b = _translate_stream_event(_event(\"bar\"), model=\"m\", tool_call_counter=counter)\n-        chunks_c = _translate_stream_event(_event(\"foo\"), model=\"m\", tool_call_counter=counter)\n-\n-        assert chunks_a[0].choices[0].delta.tool_calls[0].index == 0\n-        assert chunks_b[0].choices[0].delta.tool_calls[0].index == 1\n-        assert chunks_c[0].choices[0].delta.tool_calls[0].index == 2\n-\n-    def test_finish_reason_switches_to_tool_calls_when_any_seen(self):\n-        from agent.gemini_cloudcode_adapter import _translate_stream_event\n-\n-        counter = [0]\n-        # First event emits one tool call.\n-        _translate_stream_event(\n-            {\"response\": {\"candidates\": [{\n-                \"content\": {\"parts\": [{\"functionCall\": {\"name\": \"x\", \"args\": {}}}]},\n-            }]}},\n-            model=\"m\", tool_call_counter=counter,\n-        )\n-        # Second event carries only the terminal finishReason.\n-        chunks = _translate_stream_event(\n-            {\"response\": {\"candidates\": [{\"finishReason\": \"STOP\"}]}},\n-            model=\"m\", tool_call_counter=counter,\n-        )\n-        assert chunks[-1].choices[0].finish_reason == \"tool_calls\"\n-\n-\n-class TestMakeStreamChunk:\n-    def test_reasoning_only_chunk_has_content_none(self):\n-        from agent.gemini_cloudcode_adapter import _make_stream_chunk\n-\n-        chunk = _make_stream_chunk(model=\"m\", reasoning=\"think\")\n-        delta = chunk.choices[0].delta\n-        assert delta.content is None\n-        assert delta.reasoning == \"think\"\n-\n-    def test_content_only_chunk_has_reasoning_none(self):\n-        from agent.gemini_cloudcode_adapter import _make_stream_chunk\n-\n-        chunk = _make_stream_chunk(model=\"m\", content=\"hello\")\n-        delta = chunk.choices[0].delta\n-        assert delta.content == \"hello\"\n-        assert delta.reasoning is None\n-        assert delta.tool_calls is None\n-\n-    def test_finish_only_chunk_has_all_fields_none(self):\n-        from agent.gemini_cloudcode_adapter import _make_stream_chunk\n-\n-        chunk = _make_stream_chunk(model=\"m\", finish_reason=\"stop\")\n-        delta = chunk.choices[0].delta\n-        assert delta.content is None\n-        assert delta.reasoning is None\n-        assert delta.tool_calls is None\n-        assert chunk.choices[0].finish_reason == \"stop\"\n-\n-\n-class TestGeminiCloudCodeClient:\n-    def test_client_exposes_openai_interface(self):\n-        from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient\n-\n-        client = GeminiCloudCodeClient(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-\n-class TestGeminiHttpErrorParsing:\n-    \"\"\"Regression coverage for _gemini_http_error Google-envelope parsing.\n-\n-    These are the paths that users actually hit during Google-side throttling\n-    (April 2026: gemini-2.5-pro MODEL_CAPACITY_EXHAUSTED, gemma-4-26b-it\n-    returning 404).  The error needs to carry status_code + response so the\n-    main loop's error_classifier and Retry-After logic work.\n-    \"\"\"\n-\n-    @staticmethod\n-    def _fake_response(status: int, body: dict | str = \"\", headers=None):\n-        \"\"\"Minimal httpx.Response stand-in (duck-typed for _gemini_http_error).\"\"\"\n-        class _FakeResponse:\n-            def __init__(self):\n-                self.status_code = status\n-                if isinstance(body, dict):\n-                    self.text = json.dumps(body)\n-                else:\n-                    self.text = body\n-                self.headers = headers or {}\n-        return _FakeResponse()\n-\n-    def test_model_capacity_exhausted_produces_friendly_message(self):\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        body = {\n-            \"error\": {\n-                \"code\": 429,\n-                \"message\": \"Resource has been exhausted (e.g. check quota).\",\n-                \"status\": \"RESOURCE_EXHAUSTED\",\n-                \"details\": [\n-                    {\n-                        \"@type\": \"type.googleapis.com/google.rpc.ErrorInfo\",\n-                        \"reason\": \"MODEL_CAPACITY_EXHAUSTED\",\n-                        \"domain\": \"googleapis.com\",\n-                        \"metadata\": {\"model\": \"gemini-2.5-pro\"},\n-                    },\n-                    {\n-                        \"@type\": \"type.googleapis.com/google.rpc.RetryInfo\",\n-                        \"retryDelay\": \"30s\",\n-                    },\n-                ],\n-            }\n-        }\n-        err = _gemini_http_error(self._fake_response(429, body))\n-        assert err.status_code == 429\n-        assert err.code == \"code_assist_capacity_exhausted\"\n-        assert err.retry_after == 30.0\n-        assert err.details[\"reason\"] == \"MODEL_CAPACITY_EXHAUSTED\"\n-        # Message must be user-friendly, not a raw JSON dump.\n-        message = str(err)\n-        assert \"gemini-2.5-pro\" in message\n-        assert \"capacity exhausted\" in message.lower()\n-        assert \"30s\" in message\n-        # response attr is preserved for run_agent's Retry-After header path.\n-        assert err.response is not None\n-\n-    def test_resource_exhausted_without_reason(self):\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        body = {\n-            \"error\": {\n-                \"code\": 429,\n-                \"message\": \"Quota exceeded for requests per minute.\",\n-                \"status\": \"RESOURCE_EXHAUSTED\",\n-            }\n-        }\n-        err = _gemini_http_error(self._fake_response(429, body))\n-        assert err.status_code == 429\n-        assert err.code == \"code_assist_rate_limited\"\n-        message = str(err)\n-        assert \"quota\" in message.lower()\n-\n-    def test_404_model_not_found_produces_model_retired_message(self):\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        body = {\n-            \"error\": {\n-                \"code\": 404,\n-                \"message\": \"models/gemma-4-26b-it is not found for API version v1internal\",\n-                \"status\": \"NOT_FOUND\",\n-            }\n-        }\n-        err = _gemini_http_error(self._fake_response(404, body))\n-        assert err.status_code == 404\n-        message = str(err)\n-        assert \"not available\" in message.lower() or \"retired\" in message.lower()\n-        # Error message should reference the actual model text from Google.\n-        assert \"gemma-4-26b-it\" in message\n-\n-    def test_unauthorized_preserves_status_code(self):\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        err = _gemini_http_error(self._fake_response(\n-            401, {\"error\": {\"code\": 401, \"message\": \"Invalid token\", \"status\": \"UNAUTHENTICATED\"}},\n-        ))\n-        assert err.status_code == 401\n-        assert err.code == \"code_assist_unauthorized\"\n-\n-    def test_retry_after_header_fallback(self):\n-        \"\"\"If the body has no RetryInfo detail, fall back to Retry-After header.\"\"\"\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        resp = self._fake_response(\n-            429,\n-            {\"error\": {\"code\": 429, \"message\": \"Rate limited\", \"status\": \"RESOURCE_EXHAUSTED\"}},\n-            headers={\"Retry-After\": \"45\"},\n-        )\n-        err = _gemini_http_error(resp)\n-        assert err.retry_after == 45.0\n-\n-    def test_malformed_body_still_produces_structured_error(self):\n-        \"\"\"Non-JSON body must not swallow status_code — we still want the classifier path.\"\"\"\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-\n-        err = _gemini_http_error(self._fake_response(500, \"<html>internal error</html>\"))\n-        assert err.status_code == 500\n-        # Raw body snippet must still be there for debugging.\n-        assert \"500\" in str(err)\n-\n-    def test_status_code_flows_through_error_classifier(self):\n-        \"\"\"End-to-end: CodeAssistError from a 429 must classify as rate_limit.\n-\n-        This is the whole point of adding status_code to CodeAssistError —\n-        _extract_status_code must see it and FailoverReason.rate_limit must\n-        fire, so the main loop triggers fallback_providers.\n-        \"\"\"\n-        from agent.gemini_cloudcode_adapter import _gemini_http_error\n-        from agent.error_classifier import classify_api_error, FailoverReason\n-\n-        body = {\n-            \"error\": {\n-                \"code\": 429,\n-                \"message\": \"Resource has been exhausted\",\n-                \"status\": \"RESOURCE_EXHAUSTED\",\n-                \"details\": [\n-                    {\n-                        \"@type\": \"type.googleapis.com/google.rpc.ErrorInfo\",\n-                        \"reason\": \"MODEL_CAPACITY_EXHAUSTED\",\n-                        \"metadata\": {\"model\": \"gemini-2.5-pro\"},\n-                    }\n-                ],\n-            }\n-        }\n-        err = _gemini_http_error(self._fake_response(429, body))\n-\n-        classified = classify_api_error(\n-            err, provider=\"google-gemini-cli\", model=\"gemini-2.5-pro\",\n-        )\n-        assert classified.status_code == 429\n-        assert classified.reason == FailoverReason.rate_limit\n-\n-\n-# =============================================================================\n-# Provider registration\n-# =============================================================================\n-\n-class TestProviderRegistration:\n-    def test_registry_entry(self):\n-        from hermes_cli.auth import PROVIDER_REGISTRY\n-\n-        assert \"google-gemini-cli\" in PROVIDER_REGISTRY\n-        assert PROVIDER_REGISTRY[\"google-gemini-cli\"].auth_type == \"oauth_external\"\n-\n-    def test_google_gemini_alias_still_goes_to_api_key_gemini(self):\n-        \"\"\"Regression guard: don't shadow the existing google-gemini → gemini alias.\"\"\"\n-        from hermes_cli.auth import resolve_provider\n-\n-        assert resolve_provider(\"google-gemini\") == \"gemini\"\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-gemini-cli\")\n-        assert exc_info.value.code == \"google_oauth_not_logged_in\"\n-\n-    def test_runtime_provider_returns_correct_shape_when_logged_in(self):\n-        from agent.google_oauth import GoogleCredentials, save_credentials\n-        from hermes_cli.runtime_provider import resolve_runtime_provider\n-\n-        save_credentials(GoogleCredentials(\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-gemini-cli\")\n-        assert result[\"provider\"] == \"google-gemini-cli\"\n-        assert result[\"api_mode\"] == \"chat_completions\"\n-        assert result[\"api_key\"] == \"live-tok\"\n-        assert result[\"base_url\"] == \"cloudcode-pa://google\"\n-        assert result[\"project_id\"] == \"my-proj\"\n-        assert result[\"email\"] == \"[redacted:email]\"\n-\n-    def test_determine_api_mode(self):\n-        from hermes_cli.providers import determine_api_mode\n-\n-        assert determine_api_mode(\"google-gemini-cli\", \"cloudcode-pa://google\") == \"chat_completions\"\n-\n-    def test_oauth_capable_set_preserves_existing(self):\n-        from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS\n-\n-        for required in (\"anthropic\", \"nous\", \"openai-codex\", \"qwen-oauth\", \"google-gemini-cli\"):\n-            assert required in _OAUTH_CAPABLE_PROVIDERS\n-\n-    def test_config_env_vars_registered(self):\n-        from hermes_cli.config import OPTIONAL_ENV_VARS\n-\n-        for key in (\n-            \"HERMES_GEMINI_CLIENT_ID\",\n-            \"HERMES_GEMINI_CLIENT_SECRET\",\n-            \"HERMES_GEMINI_PROJECT_ID\",\n-        ):\n-            assert key in OPTIONAL_ENV_VARS\n-\n-\n-class TestAuthStatus:\n-    def test_not_logged_in(self):\n-        from hermes_cli.auth import get_auth_status\n-\n-        s = get_auth_status(\"google-gemini-cli\")\n-        assert s[\"logged_in\"] is False\n-\n-    def test_logged_in_reports_email_and_project(self):\n-        from agent.google_oauth import GoogleCredentials, save_credentials\n-        from hermes_cli.auth import get_auth_status\n-\n-        save_credentials(GoogleCredentials(\n-            access_token=\"tok\", refresh_token=\"rt\",\n-            expires_ms=int((time.time() + 3600) * 1000),\n-            email=\"[redacted:email]\",\n-            project_id=\"tek-proj\",\n-        ))\n-\n-        s = get_auth_status(\"google-gemini-cli\")\n-        assert s[\"logged_in\"] is True\n-        assert s[\"email\"] == \"[redacted:email]\"\n-        assert s[\"project_id\"] == \"tek-proj\"\n-\n-\n-class TestGquotaCommand:\n-    def test_gquota_registered(self):\n-        from hermes_cli.commands import COMMANDS\n-\n-        assert \"/gquota\" in COMMANDS\n-\n-\n-class TestRunGeminiOauthLoginPure:\n-    def test_returns_pool_compatible_dict(self, monkeypatch):\n-        from agent import google_oauth\n-\n-        def fake_start(**kw):\n-            return google_oauth.GoogleCredentials(\n-                access_token=\"at\", refresh_token=\"rt\",\n-                expires_ms=int((time.time() + 3600) * 1000),\n-                email=\"[redacted:email]\", project_id=\"p\",\n-            )\n-\n-        monkeypatch.setattr(google_oauth, \"start_oauth_flow\", fake_start)\n-\n-        result = google_oauth.run_gemini_oauth_login_pure()\n-        assert result[\"access_token\"] == \"at\"\n-        assert result[\"refresh_token\"] == \"rt\"\n-        assert result[\"email\"] == \"[redacted:email]\"\n-        assert result[\"project_id\"] == \"p\"\n-        assert isinstance(result[\"expires_at_ms\"], int)"]},{"file":"tests/agent/test_gemini_fast_fallback.py","hunks":["@@ -22,7 +22,7 @@ def _pool(entries: int = 2):\n def test_cloudcode_provider_skips_pool_rotation():\n     assert _pool_may_recover_from_rate_limit(\n         _pool(entries=3),\n-        provider=\"google-gemini-cli\",\n+        provider=\"auto\",\n         base_url=\"cloudcode-pa://google\",\n     ) is False\n "]},{"file":"tests/agent/transports/test_chat_completions.py","hunks":["@@ -404,34 +404,6 @@ def test_gemini_openai_compat_xhigh_clamps_to_high(self, transport):\n         )\n         assert kw[\"extra_body\"][\"extra_body\"][\"google\"][\"thinking_config\"][\"thinking_level\"] == \"high\"\n \n-    def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport):\n-        msgs = [{\"role\": \"user\", \"content\": \"Hi\"}]\n-        kw = transport.build_kwargs(\n-            model=\"gemini-3-flash-preview\",\n-            messages=msgs,\n-            provider_name=\"google-gemini-cli\",\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_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/agent/transports/test_codex_app_server_runtime.py","hunks":["@@ -85,7 +85,6 @@ def test_case_insensitive(self) -> None:\n             \"openrouter\",\n             \"xai\",\n             \"qwen-oauth\",\n-            \"google-gemini-cli\",\n             \"opencode-zen\",\n             \"bedrock\",\n             \"\","]},{"file":"/dev/null","hunks":["@@ -1,21 +0,0 @@\n-from unittest.mock import MagicMock, patch\n-\n-\n-def test_gquota_uses_chat_console_when_tui_is_live():\n-    from agent.google_oauth import GoogleOAuthError\n-    from cli import HermesCLI\n-\n-    cli = HermesCLI.__new__(HermesCLI)\n-    cli.console = MagicMock()\n-    cli._app = object()\n-\n-    live_console = MagicMock()\n-\n-    with patch(\"cli.ChatConsole\", return_value=live_console), \\\n-         patch(\"agent.google_oauth.get_valid_access_token\", side_effect=GoogleOAuthError(\"No Google OAuth credentials found\")), \\\n-         patch(\"agent.google_oauth.load_credentials\", return_value=None), \\\n-         patch(\"agent.google_code_assist.retrieve_user_quota\"):\n-        cli._handle_gquota_command(\"/gquota\")\n-\n-    assert live_console.print.call_count == 2\n-    cli.console.print.assert_not_called()"]},{"file":"tests/hermes_cli/test_auth_commands.py","hunks":["@@ -129,51 +129,6 @@ class _Args:\n     assert entry[\"expires_at_ms\"] == 1711234567000\n \n \n-def test_auth_add_google_gemini_cli_sets_active_provider(tmp_path, monkeypatch):\n-    \"\"\"hermes auth add google-gemini-cli must set active_provider in auth.json.\n-\n-    Tokens are managed by agent.google_oauth (written to the Google credential\n-    file by start_oauth_flow). The auth.json entry must record active_provider\n-    so get_active_provider() and _model_section_has_credentials() detect the\n-    provider — without storing tokens that would become stale.\n-    \"\"\"\n-    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n-    _write_auth_store(tmp_path, {\"version\": 1, \"providers\": {}})\n-    monkeypatch.setattr(\n-        \"agent.google_oauth.run_gemini_oauth_login_pure\",\n-        lambda: {\n-            \"access_token\": \"ya29.test-token\",\n-            \"refresh_token\": \"google-refresh\",\n-            \"email\": \"[redacted:email]\",\n-            \"expires_at_ms\": 9999999999000,\n-            \"project_id\": \"my-project\",\n-        },\n-    )\n-\n-    from hermes_cli.auth_commands import auth_add_command\n-\n-    class _Args:\n-        provider = \"google-gemini-cli\"\n-        auth_type = \"oauth\"\n-        api_key = None\n-        label = None\n-\n-    auth_add_command(_Args())\n-\n-    payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n-    assert payload[\"active_provider\"] == \"google-gemini-cli\"\n-    state = payload[\"providers\"][\"google-gemini-cli\"]\n-    # Only email stored — no access_token/refresh_token (those live in\n-    # the Google OAuth credential file managed by agent.google_oauth).\n-    assert state.get(\"email\") == \"[redacted:email]\"\n-    assert \"access_token\" not in state\n-    assert \"refresh_token\" not in state\n-    # pool entry from pool.add_entry() still present for hermes auth list\n-    entries = payload[\"credential_pool\"][\"google-gemini-cli\"]\n-    entry = next(item for item in entries if item[\"source\"] == \"manual:google_pkce\")\n-    assert entry[\"access_token\"] == \"ya29.test-token\"\n-\n-\n def test_auth_add_qwen_oauth_sets_active_provider(tmp_path, monkeypatch):\n     \"\"\"hermes auth add qwen-oauth must set active_provider in auth.json.\n "]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -1056,7 +1056,6 @@ def test_denylisted_keys_rejected(self, denied_key):\n     @pytest.mark.parametrize(\n         \"allowed_key\",\n         [\n-            \"HERMES_GEMINI_CLIENT_ID\",\n             \"HERMES_LANGFUSE_PUBLIC_KEY\",\n             \"HERMES_SPOTIFY_CLIENT_ID\",\n             \"HERMES_QWEN_BASE_URL\","]},{"file":"tests/hermes_cli/test_doctor.py","hunks":["@@ -473,7 +473,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon\n \n         monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {})\n         monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {})\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {})\n         monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {})\n     except Exception:\n         pass","@@ -915,7 +914,6 @@ def _run_doctor_with_healthy_oauth_fallback(\n     env_key: str,\n     bad_key: str,\n     failing_host: str,\n-    gemini_oauth_status: dict,\n     minimax_oauth_status: dict,\n     xai_oauth_status: dict | None = None,\n ) -> str:","@@ -952,7 +950,6 @@ def _run_doctor_with_healthy_oauth_fallback(\n \n     monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {\"logged_in\": True})\n     monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {})\n-    monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: gemini_oauth_status)\n     monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: minimax_oauth_status)\n     _xai_status = xai_oauth_status if xai_oauth_status is not None else {}\n     monkeypatch.setattr(_auth_mod, \"get_xai_oauth_auth_status\", lambda: _xai_status)","@@ -972,22 +969,12 @@ def fake_get(url, headers=None, timeout=None):\n \n \n @pytest.mark.parametrize(\n-    (\"env_key\", \"bad_key\", \"failing_host\", \"gemini_oauth_status\", \"minimax_oauth_status\", \"xai_oauth_status\", \"unexpected_issue\"),\n+    (\"env_key\", \"bad_key\", \"failing_host\", \"minimax_oauth_status\", \"xai_oauth_status\", \"unexpected_issue\"),\n     [\n-        (\n-            \"GOOGLE_API_KEY\",\n-            \"bad-gemini-key\",\n-            \"googleapis.com\",\n-            {\"logged_in\": True, \"email\": \"[redacted:email]\"},\n-            {},\n-            None,\n-            \"Check GOOGLE_API_KEY in .env\",\n-        ),\n         (\n             \"MINIMAX_API_KEY\",\n             \"bad-minimax-key\",\n             \"minimax.io\",\n-            {},\n             {\"logged_in\": True, \"region\": \"global\"},\n             None,\n             \"Check MINIMAX_API_KEY in .env\",","@@ -997,7 +984,6 @@ def fake_get(url, headers=None, timeout=None):\n             \"bad-xai-key\",\n             \"api.x.ai\",\n             {},\n-            {},\n             {\"logged_in\": True, \"auth_mode\": \"oauth_pkce\"},\n             \"Check XAI_API_KEY in .env\",\n         ),","@@ -1009,7 +995,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(\n     env_key,\n     bad_key,\n     failing_host,\n-    gemini_oauth_status,\n     minimax_oauth_status,\n     xai_oauth_status,\n     unexpected_issue,","@@ -1020,7 +1005,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(\n         env_key=env_key,\n         bad_key=bad_key,\n         failing_host=failing_host,\n-        gemini_oauth_status=gemini_oauth_status,\n         minimax_oauth_status=minimax_oauth_status,\n         xai_oauth_status=xai_oauth_status,\n     )","@@ -1062,16 +1046,6 @@ def test_returns_false_when_xai_import_unavailable(self, monkeypatch):\n         from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider\n         assert _has_healthy_oauth_fallback_for_apikey_provider(\"xai\") is False\n \n-    def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch):\n-        import sys\n-        from hermes_cli import auth as _auth_mod\n-        # xAI function missing, but Gemini is healthy\n-        monkeypatch.delattr(_auth_mod, \"get_xai_oauth_auth_status\", raising=False)\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {\"logged_in\": True})\n-        monkeypatch.delitem(sys.modules, \"hermes_cli.doctor\", raising=False)\n-        from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider\n-        assert _has_healthy_oauth_fallback_for_apikey_provider(\"gemini\") is True\n-\n \n # ---------------------------------------------------------------------------\n # ◆ Auth Providers — xAI OAuth display in run_doctor()","@@ -1107,7 +1081,6 @@ def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str:\n         from hermes_cli import auth as _auth_mod\n         monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {\"logged_in\": False})\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_xai_oauth_auth_status\", xai_auth_fn)\n ","@@ -1182,7 +1155,6 @@ def test_import_failure_does_not_crash_doctor(self, monkeypatch, tmp_path):\n         from hermes_cli import auth as _auth_mod\n         monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {\"logged_in\": False})\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.delattr(_auth_mod, \"get_xai_oauth_auth_status\", raising=False)\n ","@@ -1214,7 +1186,6 @@ def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_p\n         from hermes_cli import auth as _auth_mod\n         monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {\"logged_in\": True})\n         monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {\"logged_in\": False})\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.delattr(_auth_mod, \"get_xai_oauth_auth_status\", raising=False)\n ","@@ -1275,7 +1246,6 @@ def _run(self, monkeypatch, tmp_path, *, codex_logged_in: bool, codex_cli_presen\n         from hermes_cli import auth as _auth_mod\n         monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {\"logged_in\": codex_logged_in})\n-        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {\"logged_in\": False})\n         monkeypatch.setattr(_auth_mod, \"get_xai_oauth_auth_status\", lambda: {\"logged_in\": False})\n ","@@ -1317,12 +1287,16 @@ def test_hint_suppressed_when_codex_logged_in(self, monkeypatch, tmp_path):\n \n     def test_hint_never_attaches_to_minimax_row(self, monkeypatch, tmp_path):\n         out = self._run(monkeypatch, tmp_path, codex_logged_in=False, codex_cli_present=False)\n-        # The MiniMax OAuth row and the hint must not be adjacent — the hint\n-        # belongs to the Codex auth row directly above it.\n+        # The hint belongs to the Codex auth row that precedes it, never to the\n+        # MiniMax row that follows (#27975). The MiniMax row itself must not be\n+        # the hint line, and the hint must sit strictly above MiniMax.\n         lines = [l for l in out.splitlines() if l.strip()]\n+        codex_idx = next(i for i, l in enumerate(lines) if \"OpenAI Codex auth\" in l)\n+        hint_idx = next(i for i, l in enumerate(lines) if self._hint_line() in l)\n         minimax_idx = next(i for i, l in enumerate(lines) if \"MiniMax OAuth\" in l)\n-        assert self._hint_line() not in lines[minimax_idx - 1]\n-        assert minimax_idx + 1 >= len(lines) or self._hint_line() not in lines[minimax_idx + 1]\n+        # Hint sits under Codex and above MiniMax; the MiniMax row is not the hint.\n+        assert codex_idx < hint_idx < minimax_idx\n+        assert self._hint_line() not in lines[minimax_idx]\n \n \n class TestDoctorStaleMaxIterationsDrift:"]},{"file":"tests/hermes_cli/test_model_provider_persistence.py","hunks":["@@ -316,41 +316,6 @@ 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":"tests/hermes_cli/test_provider_catalog.py","hunks":["@@ -62,8 +62,6 @@ def test_api_key_providers_route_to_keys_oauth_to_accounts():\n     # api_key → keys\n     assert by[\"kilocode\"].tab == \"keys\"\n     assert by[\"openai-api\"].tab == \"keys\"\n-    # account / sign-in flows → accounts\n-    assert by[\"google-gemini-cli\"].tab == \"accounts\"\n     assert by[\"copilot-acp\"].tab == \"accounts\"\n \n "]},{"file":"tests/hermes_cli/test_web_oauth_dispatch.py","hunks":["@@ -489,14 +489,13 @@ def test_accounts_offers_every_oauth_provider_from_catalog():\n             )\n \n \n-def test_gemini_cli_and_copilot_acp_now_in_accounts():\n-    \"\"\"Regression: google-gemini-cli and copilot-acp were canonical providers the\n-    CLI could configure, but had no Accounts card (the reported GUI/CLI drift).\n+def test_copilot_acp_now_in_accounts():\n+    \"\"\"Regression: copilot-acp was a canonical provider the CLI could configure,\n+    but had no Accounts card (the reported GUI/CLI drift).\n     \"\"\"\n     resp = client.get(\"/api/providers/oauth\", headers=HEADERS)\n     assert resp.status_code == 200, resp.text\n     providers = {p[\"id\"]: p for p in resp.json()[\"providers\"]}\n-    assert \"google-gemini-cli\" in providers\n     assert \"copilot-acp\" in providers\n     # copilot-acp is managed by an external CLI: read-only card, not auto-removable.\n     assert providers[\"copilot-acp\"][\"flow\"] == \"external\""]},{"file":"/dev/null","hunks":["@@ -1,447 +0,0 @@\n-\"\"\"Regression tests for Google Workspace OAuth setup.\n-\n-These tests cover the headless/manual auth-code flow where the browser step and\n-code exchange happen in separate process invocations.\n-\"\"\"\n-\n-import importlib.util\n-import json\n-import sys\n-import types\n-from pathlib import Path\n-\n-import pytest\n-\n-\n-SCRIPT_PATH = (\n-    Path(__file__).resolve().parents[2]\n-    / \"skills/productivity/google-workspace/scripts/setup.py\"\n-)\n-\n-\n-class FakeCredentials:\n-    def __init__(self, payload=None):\n-        self._payload = payload or {\n-            \"token\": \"access-token\",\n-            \"refresh_token\": \"refresh-token\",\n-            \"token_uri\": \"https://oauth2.googleapis.com/token\",\n-            \"client_id\": \"client-id\",\n-            \"client_secret\": \"client-secret\",\n-            \"scopes\": [\n-                \"https://www.googleapis.com/auth/gmail.readonly\",\n-                \"https://www.googleapis.com/auth/gmail.send\",\n-                \"https://www.googleapis.com/auth/gmail.modify\",\n-                \"https://www.googleapis.com/auth/calendar\",\n-                \"https://www.googleapis.com/auth/drive.readonly\",\n-                \"https://www.googleapis.com/auth/contacts.readonly\",\n-                \"https://www.googleapis.com/auth/spreadsheets\",\n-                \"https://www.googleapis.com/auth/documents.readonly\",\n-            ],\n-        }\n-\n-    def to_json(self):\n-        return json.dumps(self._payload)\n-\n-\n-class FakeFlow:\n-    created = []\n-    default_state = \"generated-state\"\n-    default_verifier = \"generated-code-verifier\"\n-    credentials_payload = None\n-    fetch_error = None\n-\n-    def __init__(\n-        self,\n-        client_secrets_file,\n-        scopes,\n-        *,\n-        redirect_uri=None,\n-        state=None,\n-        code_verifier=None,\n-        autogenerate_code_verifier=False,\n-    ):\n-        self.client_secrets_file = [redacted:secret-assignment]\n-        self.scopes = scopes\n-        self.redirect_uri = redirect_uri\n-        self.state = state\n-        self.code_verifier = code_verifier\n-        self.autogenerate_code_verifier = autogenerate_code_verifier\n-        self.authorization_kwargs = None\n-        self.fetch_token_calls = []\n-        self.credentials = [redacted:secret-assignment]\n-\n-        if autogenerate_code_verifier and not self.code_verifier:\n-            self.code_verifier = self.default_verifier\n-        if not self.state:\n-            self.state = self.default_state\n-\n-    @classmethod\n-    def reset(cls):\n-        cls.created = []\n-        cls.default_state = \"generated-state\"\n-        cls.default_verifier = \"generated-code-verifier\"\n-        cls.credentials_payload = None\n-        cls.fetch_error = None\n-\n-    @classmethod\n-    def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs):\n-        inst = cls(client_secrets_file, scopes, **kwargs)\n-        cls.created.append(inst)\n-        return inst\n-\n-    def authorization_url(self, **kwargs):\n-        self.authorization_kwargs = kwargs\n-        return f\"https://auth.example/authorize?state={self.state}\", self.state\n-\n-    def fetch_token(self, **kwargs):\n-        self.fetch_token_calls.append(kwargs)\n-        if self.fetch_error:\n-            raise self.fetch_error\n-\n-\n[redacted:email]\n-def setup_module(monkeypatch, tmp_path):\n-    FakeFlow.reset()\n-\n-    google_auth_module = types.ModuleType(\"google_auth_oauthlib\")\n-    flow_module = types.ModuleType(\"google_auth_oauthlib.flow\")\n-    flow_module.Flow = FakeFlow\n-    google_auth_module.flow = flow_module\n-    monkeypatch.setitem(sys.modules, \"google_auth_oauthlib\", google_auth_module)\n-    monkeypatch.setitem(sys.modules, \"google_auth_oauthlib.flow\", flow_module)\n-\n-    spec = importlib.util.spec_from_file_location(\"google_workspace_setup_test\", SCRIPT_PATH)\n-    module = importlib.util.module_from_spec(spec)\n-    assert spec.loader is not None\n-    spec.loader.exec_module(module)\n-\n-    monkeypatch.setattr(module, \"_ensure_deps\", lambda: None)\n-    monkeypatch.setattr(module, \"CLIENT_SECRET_PATH\", tmp_path / \"google_client_secret.json\")\n-    monkeypatch.setattr(module, \"TOKEN_PATH\", tmp_path / \"google_token.json\")\n-    monkeypatch.setattr(module, \"PENDING_AUTH_PATH\", tmp_path / \"google_oauth_pending.json\", raising=False)\n-\n-    client_secret = {\n-        \"installed\": {\n-            \"client_id\": \"client-id\",\n-            \"client_secret\": \"client-secret\",\n-            \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n-            \"token_uri\": \"https://oauth2.googleapis.com/token\",\n-        }\n-    }\n-    module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret))\n-    return module\n-\n-\n-class TestGetAuthUrl:\n-    def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys):\n-        setup_module.get_auth_url()\n-\n-        out = capsys.readouterr().out.strip()\n-        assert out == \"https://auth.example/authorize?state=generated-state\"\n-\n-        saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text())\n-        assert saved[\"state\"] == \"generated-state\"\n-        assert saved[\"code_verifier\"] == \"generated-code-verifier\"\n-\n-        flow = FakeFlow.created[-1]\n-        assert flow.autogenerate_code_verifier is True\n-        assert flow.authorization_kwargs == {\"access_type\": \"offline\", \"prompt\": \"consent\"}\n-\n-\n-class TestExchangeAuthCode:\n-    def test_reuses_saved_pkce_material_for_plain_code(self, setup_module):\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-\n-        setup_module.exchange_auth_code(\"4/test-auth-code\")\n-\n-        flow = FakeFlow.created[-1]\n-        assert flow.state == \"saved-state\"\n-        assert flow.code_verifier == \"saved-verifier\"\n-        assert flow.fetch_token_calls == [{\"code\": \"4/test-auth-code\"}]\n-        saved = json.loads(setup_module.TOKEN_PATH.read_text())\n-        assert saved[\"token\"] == \"access-token\"\n-        assert saved[\"type\"] == \"authorized_user\"\n-        assert not setup_module.PENDING_AUTH_PATH.exists()\n-\n-    def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module):\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-\n-        setup_module.exchange_auth_code(\n-            \"http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail\"\n-        )\n-\n-        flow = FakeFlow.created[-1]\n-        assert flow.fetch_token_calls == [{\"code\": \"4/extracted-code\"}]\n-\n-    def test_passes_scopes_from_redirect_url_to_flow(self, setup_module):\n-        \"\"\"Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES).\"\"\"\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-        g1 = \"https://www.googleapis.com/auth/gmail.readonly\"\n-        g2 = \"https://www.googleapis.com/auth/calendar\"\n-        from urllib.parse import quote\n-\n-        scope_q = quote(f\"{g1} {g2}\", safe=\"\")\n-        setup_module.exchange_auth_code(\n-            f\"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}\"\n-        )\n-        flow = FakeFlow.created[-1]\n-        assert flow.scopes == [g1, g2]\n-\n-    def test_rejects_state_mismatch(self, setup_module, capsys):\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-\n-        with pytest.raises(SystemExit):\n-            setup_module.exchange_auth_code(\n-                \"http://localhost:1/?code=4/extracted-code&state=wrong-state\"\n-            )\n-\n-        out = capsys.readouterr().out\n-        assert \"state mismatch\" in out.lower()\n-        assert not setup_module.TOKEN_PATH.exists()\n-\n-    def test_requires_pending_auth_session(self, setup_module, capsys):\n-        with pytest.raises(SystemExit):\n-            setup_module.exchange_auth_code(\"4/test-auth-code\")\n-\n-        out = capsys.readouterr().out\n-        assert \"run --auth-url first\" in out.lower()\n-        assert not setup_module.TOKEN_PATH.exists()\n-\n-    def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys):\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-        FakeFlow.fetch_error = Exception(\"invalid_grant: Missing code verifier\")\n-\n-        with pytest.raises(SystemExit):\n-            setup_module.exchange_auth_code(\"4/test-auth-code\")\n-\n-        out = capsys.readouterr().out\n-        assert \"token exchange failed\" in out.lower()\n-        assert setup_module.PENDING_AUTH_PATH.exists()\n-        assert not setup_module.TOKEN_PATH.exists()\n-\n-    def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):\n-        \"\"\"Partial scopes are accepted with a warning (gws migration: v2.0).\"\"\"\n-        setup_module.PENDING_AUTH_PATH.write_text(\n-            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n-        )\n-        setup_module.TOKEN_PATH.write_text(json.dumps({\"token\": \"***\", \"scopes\": setup_module.SCOPES}))\n-        FakeFlow.credentials_payload = {\n-            \"token\": \"***\",\n-            \"refresh_token\": \"***\",\n-            \"token_uri\": \"https://oauth2.googleapis.com/token\",\n-            \"client_id\": \"client-id\",\n-            \"client_secret\": \"client-secret\",\n-            \"scopes\": [\n-                \"https://www.googleapis.com/auth/drive.readonly\",\n-                \"https://www.googleapis.com/auth/spreadsheets\",\n-            ],\n-        }\n-\n-        setup_module.exchange_auth_code(\"4/test-auth-code\")\n-\n-        out = capsys.readouterr().out\n-        assert \"warning\" in out.lower()\n-        assert \"missing\" in out.lower()\n-        # Token is saved (partial scopes accepted)\n-        assert setup_module.TOKEN_PATH.exists()\n-        # Pending auth is cleaned up\n-        assert not setup_module.PENDING_AUTH_PATH.exists()\n-\n-\n-class TestHermesConstantsFallback:\n-    \"\"\"Tests for _hermes_home.py fallback when hermes_constants is unavailable.\"\"\"\n-\n-    HELPER_PATH = (\n-        Path(__file__).resolve().parents[2]\n-        / \"skills/productivity/google-workspace/scripts/_hermes_home.py\"\n-    )\n-\n-    def _load_helper(self, monkeypatch):\n-        \"\"\"Load _hermes_home.py with hermes_constants blocked.\"\"\"\n-        monkeypatch.setitem(sys.modules, \"hermes_constants\", None)\n-        spec = importlib.util.spec_from_file_location(\"_hermes_home_test\", self.HELPER_PATH)\n-        module = importlib.util.module_from_spec(spec)\n-        assert spec.loader is not None\n-        spec.loader.exec_module(module)\n-        return module\n-\n-    def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path):\n-        \"\"\"When hermes_constants is missing, HERMES_HOME comes from env var.\"\"\"\n-        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"custom-hermes\"))\n-        module = self._load_helper(monkeypatch)\n-        assert module.get_hermes_home() == tmp_path / \"custom-hermes\"\n-\n-    def test_fallback_defaults_to_dot_hermes(self, monkeypatch):\n-        \"\"\"When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes.\"\"\"\n-        monkeypatch.delenv(\"HERMES_HOME\", raising=False)\n-        module = self._load_helper(monkeypatch)\n-        assert module.get_hermes_home() == Path.home() / \".hermes\"\n-\n-    def test_fallback_ignores_empty_hermes_home(self, monkeypatch):\n-        \"\"\"Empty/whitespace HERMES_HOME is treated as unset.\"\"\"\n-        monkeypatch.setenv(\"HERMES_HOME\", \"  \")\n-        module = self._load_helper(monkeypatch)\n-        assert module.get_hermes_home() == Path.home() / \".hermes\"\n-\n-    def test_fallback_display_hermes_home_shortens_path(self, monkeypatch):\n-        \"\"\"Fallback display_hermes_home() uses ~/ shorthand like the real one.\"\"\"\n-        monkeypatch.delenv(\"HERMES_HOME\", raising=False)\n-        module = self._load_helper(monkeypatch)\n-        assert module.display_hermes_home() == \"~/.hermes\"\n-\n-    def test_fallback_display_hermes_home_profile_path(self, monkeypatch):\n-        \"\"\"Fallback display_hermes_home() handles profile paths under ~/.\"\"\"\n-        monkeypatch.setenv(\"HERMES_HOME\", str(Path.home() / \".hermes/profiles/coder\"))\n-        module = self._load_helper(monkeypatch)\n-        assert module.display_hermes_home() == \"~/.hermes/profiles/coder\"\n-\n-    def test_fallback_display_hermes_home_custom_path(self, monkeypatch):\n-        \"\"\"Fallback display_hermes_home() returns full path for non-home locations.\"\"\"\n-        monkeypatch.setenv(\"HERMES_HOME\", \"/opt/hermes-custom\")\n-        module = self._load_helper(monkeypatch)\n-        assert module.display_hermes_home() == \"/opt/hermes-custom\"\n-\n-    def test_delegates_to_hermes_constants_when_available(self):\n-        \"\"\"When hermes_constants IS importable, _hermes_home delegates to it.\"\"\"\n-        spec = importlib.util.spec_from_file_location(\n-            \"_hermes_home_happy\", self.HELPER_PATH\n-        )\n-        module = importlib.util.module_from_spec(spec)\n-        assert spec.loader is not None\n-        spec.loader.exec_module(module)\n-        import hermes_constants\n-        assert module.get_hermes_home is hermes_constants.get_hermes_home\n-        assert module.display_hermes_home is hermes_constants.display_hermes_home\n-\n-\n-def _load_setup_module(monkeypatch):\n-    \"\"\"Load setup.py without stubbing _ensure_deps (for install_deps tests).\"\"\"\n-    spec = importlib.util.spec_from_file_location(\n-        \"google_workspace_setup_installdeps_test\", SCRIPT_PATH\n-    )\n-    module = importlib.util.module_from_spec(spec)\n-    assert spec.loader is not None\n-    spec.loader.exec_module(module)\n-    return module\n-\n-\n-def _force_deps_missing(monkeypatch):\n-    \"\"\"Make `import googleapiclient` / `import google_auth_oauthlib` fail so\n-    install_deps() proceeds past its early-return short-circuit.\"\"\"\n-    for name in (\"googleapiclient\", \"google_auth_oauthlib\"):\n-        monkeypatch.setitem(sys.modules, name, None)\n-\n-\n-class TestInstallDeps:\n-    \"\"\"Tests for install_deps() interpreter/installer selection.\n-\n-    Regression coverage for the Hermes Docker image, whose venv is built with\n-    `uv sync` and ships without pip — `sys.executable -m pip install` fails\n-    with `No module named pip`, so install_deps() must fall back to uv.\n-    \"\"\"\n-\n-    def test_returns_early_when_already_installed(self, monkeypatch):\n-        \"\"\"If both libs import, no installer subprocess runs at all.\"\"\"\n-        module = _load_setup_module(monkeypatch)\n-        # Don't force-missing: real test env has the libs importable. Guard\n-        # against any subprocess being spawned.\n-        calls = []\n-        monkeypatch.setattr(\n-            module.subprocess, \"check_call\", lambda *a, **k: calls.append(a)\n-        )\n-        # google_auth_oauthlib may not be installed in the test env; only run\n-        # this assertion when the early-return path is actually reachable.\n-        try:\n-            import googleapiclient  # noqa: F401\n-            import google_auth_oauthlib  # noqa: F401\n-        except ImportError:\n-            pytest.skip(\"Google libs not installed in test env\")\n-        assert module.install_deps() is True\n-        assert calls == []\n-\n-    def test_uses_pip_when_available(self, monkeypatch):\n-        \"\"\"When pip works, install_deps succeeds via pip and never calls uv.\"\"\"\n-        module = _load_setup_module(monkeypatch)\n-        _force_deps_missing(monkeypatch)\n-\n-        recorded = []\n-\n-        def fake_check_call(cmd, **kwargs):\n-            recorded.append(cmd)\n-            # pip path is the first attempt — succeed.\n-            return 0\n-\n-        which_calls = []\n-        monkeypatch.setattr(module.subprocess, \"check_call\", fake_check_call)\n-        monkeypatch.setattr(\n-            module.shutil, \"which\", lambda name: which_calls.append(name)\n-        )\n-\n-        assert module.install_deps() is True\n-        assert recorded[0][:3] == [module.sys.executable, \"-m\", \"pip\"]\n-        # Control: uv must NOT be consulted when pip succeeds.\n-        assert which_calls == []\n-\n-    def test_falls_back_to_uv_when_pip_missing(self, monkeypatch):\n-        \"\"\"No pip → uv pip install --python <interpreter> is used.\"\"\"\n-        module = _load_setup_module(monkeypatch)\n-        _force_deps_missing(monkeypatch)\n-\n-        recorded = []\n-\n-        def fake_check_call(cmd, **kwargs):\n-            recorded.append(cmd)\n-            if cmd[:3] == [module.sys.executable, \"-m\", \"pip\"]:\n-                raise module.subprocess.CalledProcessError(1, cmd)\n-            return 0  # uv invocation succeeds\n-\n-        monkeypatch.setattr(module.subprocess, \"check_call\", fake_check_call)\n-        monkeypatch.setattr(module.shutil, \"which\", lambda name: \"/usr/local/bin/uv\")\n-\n-        assert module.install_deps() is True\n-        assert len(recorded) == 2\n-        uv_cmd = recorded[1]\n-        assert uv_cmd[0] == \"/usr/local/bin/uv\"\n-        assert uv_cmd[1:5] == [\"pip\", \"install\", \"--python\", module.sys.executable]\n-        for pkg in module.REQUIRED_PACKAGES:\n-            assert pkg in uv_cmd\n-\n-    def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys):\n-        \"\"\"No pip AND no uv → failure, with the [google] extra hint printed.\"\"\"\n-        module = _load_setup_module(monkeypatch)\n-        _force_deps_missing(monkeypatch)\n-\n-        def fake_check_call(cmd, **kwargs):\n-            raise module.subprocess.CalledProcessError(1, cmd)\n-\n-        monkeypatch.setattr(module.subprocess, \"check_call\", fake_check_call)\n-        monkeypatch.setattr(module.shutil, \"which\", lambda name: None)\n-\n-        assert module.install_deps() is False\n-        out = capsys.readouterr().out\n-        assert \"hermes-agent[google]\" in out\n-\n-    def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys):\n-        \"\"\"uv present but its install fails → failure surfaced (not swallowed).\"\"\"\n-        module = _load_setup_module(monkeypatch)\n-        _force_deps_missing(monkeypatch)\n-\n-        def fake_check_call(cmd, **kwargs):\n-            raise module.subprocess.CalledProcessError(1, cmd)\n-\n-        monkeypatch.setattr(module.subprocess, \"check_call\", fake_check_call)\n-        monkeypatch.setattr(module.shutil, \"which\", lambda name: \"/usr/local/bin/uv\")\n-\n-        assert module.install_deps() is False\n-        out = capsys.readouterr().out\n-        assert \"via uv\" in out"]},{"file":"website/docs/developer-guide/adding-providers.md","hunks":["@@ -127,7 +127,7 @@ See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a tem\n \n Use the full checklist below when your provider needs any of the following:\n \n-- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot)\n+- OAuth or token refresh (Nous Portal, Codex, Qwen Portal, Copilot)\n - A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)\n - Custom endpoint detection or multi-region probing (z.ai, Kimi)\n - A curated static model catalog or live `/models` fetch"]},{"file":"website/docs/developer-guide/model-provider-plugin.md","hunks":["@@ -195,7 +195,7 @@ Set `profile.api_mode` to match the default your provider ships — it acts as a\n |---|---|---|\n | `api_key` | Single env var carries a static API key | Most providers |\n | `oauth_device_code` | Device-code OAuth flow | — |\n-| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Gemini Cloud Code, Qwen Portal, Nous Portal |\n+| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Qwen Portal, Nous Portal |\n | `copilot` | GitHub Copilot token refresh cycle | `copilot` plugin only |\n | `aws_sdk` | AWS SDK credential chain (IAM role, profile, env) | `bedrock` plugin only |\n | `external_process` | Auth handled by a subprocess the agent spawns | `copilot-acp` plugin only |"]},{"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`, `google-antigravity`)\n+- Google / Gemini (`gemini`)\n - Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)\n - DeepSeek\n - Z.AI"]},{"file":"website/docs/getting-started/quickstart.md","hunks":["@@ -126,7 +126,6 @@ Good defaults:\n | **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) |\n | **Azure Foundry** | Azure AI Foundry-hosted models | Set `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |\n | **Google AI Studio** | Gemini models via direct API | Set `GOOGLE_API_KEY` / `GEMINI_API_KEY` |\n-| **Google Gemini (OAuth)** | Gemini via the `google-gemini-cli` OAuth flow — no key needed | `hermes model` → Google Gemini (OAuth) |\n | **xAI** | Grok models via direct API | Set `XAI_API_KEY` |\n | **xAI Grok OAuth** | SuperGrok / Premium+ subscription, no API key needed | `hermes model` → xAI Grok OAuth |\n | **NovitaAI** | Multi-model API gateway | Set `NOVITA_API_KEY` |"]},{"file":"website/docs/guides/google-gemini.md","hunks":["@@ -1,15 +1,13 @@\n ---\n sidebar_position: 16\n title: \"Google Gemini\"\n-description: \"Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, OAuth option, tool calling, streaming, and quota guidance\"\n+description: \"Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, tool calling, streaming, and quota guidance\"\n ---\n \n # Google Gemini\n \n Hermes Agent supports Google Gemini as a native provider using the **Google AI Studio / Gemini API** — not the OpenAI-compatible endpoint. This lets Hermes translate its internal OpenAI-shaped message and tool loop into Gemini's native `generateContent` API while preserving tool calling, streaming, multimodal inputs, and Gemini-specific response metadata.\n \n-Hermes also supports a separate **Google Gemini (OAuth)** provider that uses the same Cloud Code Assist backend as Google's Gemini CLI. Use the API-key provider (`gemini`) for the lowest-risk official API path.\n-\n ## Prerequisites\n \n - **Google AI Studio API key** — create one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)","@@ -100,30 +98,6 @@ If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or chang\n GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta\n ```\n \n-### OAuth Provider\n-\n-Hermes also has a `google-gemini-cli` provider:\n-\n-```bash\n-hermes model\n-# → Choose \"Google Gemini (OAuth)\"\n-```\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:","@@ -205,18 +179,8 @@ hermes doctor\n 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:\n-\n-```text\n-/gquota\n-```\n-\n-`/gquota` applies to the `google-gemini-cli` OAuth provider, not the AI Studio API-key provider.\n-\n ## Gateway (Messaging Platforms)\n \n Gemini works with all Hermes gateway platforms (Telegram, Discord, Slack, WhatsApp, LINE, Feishu, etc.). Configure Gemini as your provider, then start the gateway normally:","@@ -278,10 +242,6 @@ Change it to the native endpoint or remove the override:\n GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta\n ```\n \n-### OAuth login warning\n-\n-The `google-gemini-cli` provider uses a Gemini CLI / Cloud Code Assist OAuth flow. Hermes warns before starting it because this is distinct from the official AI Studio API-key path. Use `provider: gemini` with `GOOGLE_API_KEY` for the official API-key integration.\n-\n ### Tool calling fails with schema errors\n \n Upgrade Hermes and rerun `hermes model`. The native Gemini adapter sanitizes tool schemas for Gemini's stricter function-declaration format; older builds or custom endpoints may not."]},{"file":"website/docs/integrations/providers.md","hunks":["@@ -40,7 +40,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro\n | **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |\n | **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) |\n | **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) |\n-| **Google Gemini (OAuth)** | `hermes model` → \"Google Gemini (OAuth)\" (provider: `google-gemini-cli`, free tier supported, browser PKCE login) |\n | **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) |\n | **Azure AI Foundry** | `hermes model` → \"Azure AI Foundry\" (provider: `azure-foundry`; uses Azure OpenAI / Foundry endpoint and key) |\n | **AWS Bedrock** | `hermes model` → \"AWS Bedrock\" (provider: `bedrock`; standard AWS credentials chain via boto3) |","@@ -49,7 +48,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro\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 ","@@ -79,64 +77,6 @@ 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 ","@@ -592,91 +532,6 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest\n \n The base URL can be overridden with `HF_BASE_URL`.\n \n-### Google Gemini via OAuth (`google-gemini-cli`)\n-\n-The `google-gemini-cli` provider uses Google's Cloud Code Assist backend — the\n-same API that Google's own `gemini-cli` tool uses. This supports both the\n-**free tier** (generous daily quota for personal accounts) and **paid tiers**\n-(Standard/Enterprise via a GCP project).\n-\n-**Quick start:**\n-\n-```bash\n-hermes model\n-# → pick \"Google Gemini (OAuth)\"\n-# → see policy warning, confirm\n-# → browser opens to accounts.google.com, sign in\n-# → done — Hermes auto-provisions your free tier on first request\n-```\n-\n-Hermes ships Google's **public** `gemini-cli` desktop OAuth client by default —\n-the same credentials Google includes in their open-source `gemini-cli`. Desktop\n-OAuth clients are not confidential (PKCE provides the security). You do not\n-need to install `gemini-cli` or register your own GCP OAuth client.\n-\n-**How auth works:**\n-- PKCE Authorization Code flow against `accounts.google.com`\n-- Browser callback at `http://127.0.0.1:8085/oauth2callback` (with ephemeral-port fallback if busy)\n-- Tokens stored at `~/.hermes/auth/google_oauth.json` (chmod 0600, atomic write, cross-process `fcntl` lock)\n-- Automatic refresh 60 s before expiry\n-- Headless environments (SSH, `HERMES_HEADLESS=1`) → paste-mode fallback\n-- Inflight refresh deduplication — two concurrent requests won't double-refresh\n-- `invalid_grant` (revoked refresh) → credential file wiped, user prompted to re-login\n-\n-**How inference works:**\n-- Traffic goes to `https://cloudcode-pa.googleapis.com/v1internal:generateContent`\n-  (or `:streamGenerateContent?alt=sse` for streaming), NOT the paid `v1beta/openai` endpoint\n-- Request body wrapped `{project, model, user_prompt_id, request}`\n-- OpenAI-shaped `messages[]`, `tools[]`, `tool_choice` are translated to Gemini's native\n-  `contents[]`, `tools[].functionDeclarations`, `toolConfig` shape\n-- Responses translated back to OpenAI shape so the rest of Hermes works unchanged\n-\n-**Tiers & project IDs:**\n-\n-| Your situation | What to do |\n-|---|---|\n-| Personal Google account, want free tier | Nothing — sign in, start chatting |\n-| Workspace / Standard / Enterprise account | Set `HERMES_GEMINI_PROJECT_ID` or `GOOGLE_CLOUD_PROJECT` to your GCP project ID |\n-| VPC-SC-protected org | Hermes detects `SECURITY_POLICY_VIOLATED` and forces `standard-tier` automatically |\n-\n-Free tier auto-provisions a Google-managed project on first use. No GCP setup required.\n-\n-**Quota monitoring:**\n-\n-```\n-/gquota\n-```\n-\n-Shows remaining Code Assist quota per model with progress bars:\n-\n-```\n-Gemini Code Assist quota  (project: 123-abc)\n-\n-  gemini-2.5-pro                      ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░   85%\n-  gemini-2.5-flash [input]            ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░   92%\n-```\n-\n-:::warning Policy risk\n-Google considers using the Gemini CLI OAuth client with third-party software a\n-policy violation. Some users have reported account restrictions. For the lowest-risk\n-experience, use your own API key via the `gemini` provider instead. Hermes shows\n-an upfront warning and requires explicit confirmation before OAuth begins.\n-:::\n-\n-**Custom OAuth client (optional):**\n-\n-If you'd rather register your own Google OAuth client — e.g., to keep quota\n-and consent scoped to your own GCP project — set:\n-\n-```bash\n-HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com\n-HERMES_GEMINI_CLIENT_SECRET=...   # optional for Desktop clients\n-```\n-\n-Register a **Desktop app** OAuth client at\n-[console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)\n-with the Generative Language API enabled.\n-\n ## Custom & Self-Hosted LLM Providers\n \n Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API.","@@ -1591,7 +1446,7 @@ fallback_model:\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`, `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+Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.\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`, `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+| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |\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":["@@ -67,13 +67,6 @@ Hermes reads environment variables from the process environment and, for user-ma\n | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) |\n | `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` |\n | `GEMINI_BASE_URL` | Override Google AI Studio base URL |\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, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy)\n+- **Google** — Gemini models (direct API via `gemini` 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/reference/slash-commands.md","hunks":["@@ -115,7 +115,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in\n | `/image <path>` | Attach a local image file for your next prompt. |\n | `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |\n | `/profile` | Show active profile name and home directory |\n-| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). |\n \n ### Exit\n ","@@ -246,7 +245,7 @@ The messaging gateway supports the following built-in commands inside Telegram,\n \n ## Notes\n \n-- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.\n+- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.\n - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.\n - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.\n - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands."]},{"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`, `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+Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: \"beans\"`).\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":["@@ -62,8 +62,6 @@ Each entry requires both `provider` and `model`. Entries missing either field ar\n | GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) |\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) |"]},{"file":"website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md","hunks":["@@ -343,7 +343,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer\n /commands [page]     Browse all commands (gateway)\n /usage               Token usage\n /insights [days]     Usage analytics\n-/gquota              Show Google Gemini Code Assist quota usage (CLI)\n /status              Session info (gateway)\n /profile             Active profile info\n /debug               Upload debug report (system info + logs) and get shareable links"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md","hunks":["@@ -127,7 +127,7 @@ Hermes 已经可以通过自定义 provider 路径与任何 OpenAI 兼容的端\n \n 当你的 provider 需要以下任何内容时，使用下面的完整清单：\n \n-- OAuth 或 token 刷新（Nous Portal、Codex、Google Gemini、Qwen Portal、Copilot）\n+- OAuth 或 token 刷新（Nous Portal、Codex、Qwen Portal、Copilot）\n - 需要新适配器的非 OpenAI API 格式（Anthropic Messages、Codex Responses）\n - 自定义端点检测或多区域探测（z.ai、Kimi）\n - 精选的静态模型目录或实时 `/models` 获取"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md","hunks":["@@ -194,7 +194,7 @@ register_provider(ProviderProfile(\n |---|---|---|\n | `api_key` | 单个环境变量携带静态 API key | 大多数提供商 |\n | `oauth_device_code` | 设备码 OAuth 流程 | — |\n-| `oauth_external` | 用户在其他地方登录，token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Gemini Cloud Code、Qwen Portal、Nous Portal |\n+| `oauth_external` | 用户在其他地方登录，token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Qwen Portal、Nous Portal |\n | `copilot` | GitHub Copilot token 刷新周期 | 仅 `copilot` 插件 |\n | `aws_sdk` | AWS SDK 凭据链（IAM role、profile、env） | 仅 `bedrock` 插件 |\n | `external_process` | 认证由 agent 启动的子进程处理 | 仅 `copilot-acp` 插件 |"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md","hunks":["@@ -47,7 +47,7 @@ Hermes 拥有一个共享的 provider 运行时解析器，用于以下场景：\n - OpenAI Codex\n - Copilot / Copilot ACP\n - Anthropic（原生）\n-- Google / Gemini（`gemini`、`google-gemini-cli`）\n+- Google / Gemini（`gemini`）\n - Alibaba / DashScope（`alibaba`、`alibaba-coding-plan`）\n - DeepSeek\n - Z.AI"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md","hunks":["@@ -1,15 +1,13 @@\n ---\n sidebar_position: 16\n title: \"Google Gemini\"\n-description: \"将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、OAuth 选项、工具调用、流式传输及配额说明\"\n+description: \"将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、工具调用、流式传输及配额说明\"\n ---\n \n # Google Gemini\n \n Hermes Agent 通过 **Google AI Studio / Gemini API** 原生支持 Google Gemini——而非 OpenAI 兼容端点。这使 Hermes 能够将其内部 OpenAI 格式的消息和工具循环转换为 Gemini 原生的 `generateContent` API，同时保留工具调用、流式传输、多模态输入以及 Gemini 特有的响应元数据。\n \n-Hermes 还支持独立的 **Google Gemini（OAuth）** provider，使用与 Google Gemini CLI 相同的 Cloud Code Assist 后端。如需最低风险的官方 API 路径，请使用 API 密钥 provider（`gemini`）。\n-\n ## 前提条件\n \n - **Google AI Studio API 密钥** — 在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建","@@ -100,17 +98,6 @@ https://generativelanguage.googleapis.com/v1beta/openai/\n GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta\n ```\n \n-### OAuth Provider\n-\n-Hermes 还提供 `google-gemini-cli` provider：\n-\n-```bash\n-hermes model\n-# → 选择 \"Google Gemini (OAuth)\"\n-```\n-\n-该方式使用浏览器 PKCE 登录和 Cloud Code Assist 后端。对于希望使用 Gemini CLI 风格 OAuth 的用户可能有用，但 Hermes 会显示明确警告，因为 Google 可能将第三方软件使用 Gemini CLI OAuth 客户端的行为视为违反政策。对于生产环境或最低风险使用场景，请优先使用上述 API 密钥 provider。\n-\n ## 可用模型\n \n `hermes model` 选择器显示 Hermes provider 注册表中维护的 Gemini 模型。常见选项包括：","@@ -192,17 +179,8 @@ hermes doctor\n doctor 命令检查：\n \n - `GOOGLE_API_KEY` 或 `GEMINI_API_KEY` 是否可用\n-- `google-gemini-cli` 的 Gemini OAuth 凭据是否存在\n - 已配置的 provider 凭据是否可以解析\n \n-如需查看 OAuth 配额使用情况，请在 Hermes 会话中运行：\n-\n-```text\n-/gquota\n-```\n-\n-`/gquota` 适用于 `google-gemini-cli` OAuth provider，不适用于 AI Studio API 密钥 provider。\n-\n ## Gateway（消息平台）\n \n Gemini 可与所有 Hermes gateway 平台配合使用（Telegram、Discord、Slack、WhatsApp、LINE、飞书等）。将 Gemini 配置为你的 provider，然后正常启动 gateway：","@@ -264,10 +242,6 @@ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/\n GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta\n ```\n \n-### OAuth 登录警告\n-\n-`google-gemini-cli` provider 使用 Gemini CLI / Cloud Code Assist OAuth 流程。Hermes 在启动前会发出警告，因为这与官方 AI Studio API 密钥路径不同。如需官方 API 密钥集成，请使用 `provider: gemini` 配合 `GOOGLE_API_KEY`。\n-\n ### 工具调用因 schema 错误而失败\n \n 升级 Hermes 并重新运行 `hermes model`。原生 Gemini 适配器会针对 Gemini 更严格的函数声明格式对工具 schema 进行清理；旧版本或自定义端点可能不支持此功能。"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md","hunks":["@@ -40,7 +40,6 @@ sidebar_position: 1\n | **DeepSeek** | `~/.hermes/.env` 中的 `DEEPSEEK_API_KEY`（provider: `deepseek`） |\n | **Hugging Face** | `~/.hermes/.env` 中的 `HF_TOKEN`（provider: `huggingface`，别名：`hf`） |\n | **Google / Gemini** | `~/.hermes/.env` 中的 `GOOGLE_API_KEY`（或 `GEMINI_API_KEY`）（provider: `gemini`） |\n-| **Google Gemini（OAuth）** | `hermes model` → \"Google Gemini (OAuth)\"（provider: `google-gemini-cli`，支持免费层，浏览器 PKCE 登录） |\n | **LM Studio** | `hermes model` → \"LM Studio\"（provider: `lmstudio`，可选 `LM_API_KEY`） |\n | **自定义端点** | `hermes model` → 选择\"Custom endpoint\"（保存在 `config.yaml`） |\n ","@@ -512,79 +511,6 @@ model:\n \n 基础 URL 可通过 `HF_BASE_URL` 覆盖。\n \n-### 通过 OAuth 使用 Google Gemini（`google-gemini-cli`）\n-\n-`google-gemini-cli` 提供商使用 Google 的 Cloud Code Assist 后端——与 Google 自己的 `gemini-cli` 工具使用的 API 相同。支持**免费层**（个人账户每日配额充足）和**付费层**（通过 GCP 项目的 Standard/Enterprise）。\n-\n-**快速开始：**\n-\n-```bash\n-hermes model\n-# → 选择\"Google Gemini (OAuth)\"\n-# → 查看政策警告，确认\n-# → 浏览器打开 accounts.google.com，登录\n-# → 完成——Hermes 在首次请求时自动开通免费层\n-```\n-\n-Hermes 默认使用 Google 的**公开** `gemini-cli` 桌面 OAuth 客户端——与 Google 在其开源 `gemini-cli` 中包含的凭据相同。桌面 OAuth 客户端不是机密客户端（PKCE 提供安全保障）。你无需安装 `gemini-cli` 或注册自己的 GCP OAuth 客户端。\n-\n-**认证工作原理：**\n-- 针对 `accounts.google.com` 的 PKCE 授权码流程\n-- 浏览器回调地址 `http://127.0.0.1:8085/oauth2callback`（端口占用时自动回退到临时端口）\n-- Token 存储在 `~/.hermes/auth/google_oauth.json`（chmod 0600，原子写入，跨进程 `fcntl` 锁）\n-- 到期前 60 秒自动刷新\n-- 无头环境（SSH、`HERMES_HEADLESS=1`）→ 粘贴模式回退\n-- 并发刷新去重——两个并发请求不会触发双重刷新\n-- `invalid_grant`（刷新 token 被撤销）→ 凭据文件被清除，提示用户重新登录\n-\n-**推理工作原理：**\n-- 流量发送到 `https://cloudcode-pa.googleapis.com/v1internal:generateContent`\n-  （流式传输为 `:streamGenerateContent?alt=sse`），而非付费的 `v1beta/openai` 端点\n-- 请求体封装为 `{project, model, user_prompt_id, request}`\n-- OpenAI 格式的 `messages[]`、`tools[]`、`tool_choice` 被转换为 Gemini 原生的\n-  `contents[]`、`tools[].functionDeclarations`、`toolConfig` 格式\n-- 响应转换回 OpenAI 格式，Hermes 其余部分无感知\n-\n-**层级与项目 ID：**\n-\n-| 你的情况 | 操作 |\n-|---|---|\n-| 个人 Google 账户，使用免费层 | 无需操作——登录即可开始聊天 |\n-| Workspace / Standard / Enterprise 账户 | 将 `HERMES_GEMINI_PROJECT_ID` 或 `GOOGLE_CLOUD_PROJECT` 设置为你的 GCP 项目 ID |\n-| VPC-SC 保护的组织 | Hermes 检测到 `SECURITY_POLICY_VIOLATED` 后自动强制使用 `standard-tier` |\n-\n-免费层在首次使用时自动开通 Google 托管项目。无需 GCP 配置。\n-\n-**配额监控：**\n-\n-```\n-/gquota\n-```\n-\n-以进度条显示每个模型的剩余 Code Assist 配额：\n-\n-```\n-Gemini Code Assist quota  (project: 123-abc)\n-\n-  gemini-2.5-pro                      ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░   85%\n-  gemini-2.5-flash [input]            ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░   92%\n-```\n-\n-:::warning 政策风险\n-Google 认为将 Gemini CLI OAuth 客户端用于第三方软件违反政策。部分用户反映账户受到限制。为降低风险，建议改用 `gemini` 提供商并通过 API key 访问。Hermes 会在 OAuth 开始前显示警告并要求明确确认。\n-:::\n-\n-**自定义 OAuth 客户端（可选）：**\n-\n-如果你希望注册自己的 Google OAuth 客户端——例如将配额和授权范围限定在自己的 GCP 项目内——请设置：\n-\n-```bash\n-HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com\n-HERMES_GEMINI_CLIENT_SECRET=...   # 桌面客户端可选\n-```\n-\n-在 [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) 注册一个**桌面应用** OAuth 客户端，并启用 Generative Language API。\n-\n ## 自定义与自托管 LLM 提供商\n \n Hermes Agent 可与**任何 OpenAI 兼容 API 端点**配合使用。只要服务器实现了 `/v1/chat/completions`，就可以将 Hermes 指向它。这意味着你可以使用本地模型、GPU 推理服务器、多提供商路由器或任何第三方 API。","@@ -1477,7 +1403,7 @@ fallback_model:\n \n 激活时，故障转移在不丢失对话的情况下中途切换模型和提供商。链按条目逐一尝试；每个会话激活一次。\n \n-支持的提供商：`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。\n+支持的提供商：`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。\n \n :::tip\n 故障转移仅通过 `config.yaml` 配置——或通过 `hermes fallback` 交互式配置。有关触发时机、链推进方式以及与辅助任务和委托的交互，参见[故障转移提供商](/user-guide/features/fallback-providers)。"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md","hunks":["@@ -95,7 +95,7 @@ hermes chat [options]\n | `-q`, `--query \"...\"` | 单次非交互式 prompt。 |\n | `-m`, `--model <model>` | 覆盖本次运行的模型。 |\n | `-t`, `--toolsets <csv>` | 启用逗号分隔的 toolset 集合。 |\n-| `--provider <provider>` | 强制指定 provider：`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`google-gemini-cli`、`huggingface`、`novita`（别名 `novita-ai`、`novitaai`）、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`（别名 `alibaba_coding`）、`deepseek`、`nvidia`、`ollama-cloud`、`xai`（别名 `grok`）、`xai-oauth`（别名 `grok-oauth`）、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`（别名 `tencent`、`tokenhub`）。 |\n+| `--provider <provider>` | 强制指定 provider：`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`（别名 `novita-ai`、`novitaai`）、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`（别名 `alibaba_coding`）、`deepseek`、`nvidia`、`ollama-cloud`、`xai`（别名 `grok`）、`xai-oauth`（别名 `grok-oauth`）、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`（别名 `tencent`、`tokenhub`）。 |\n | `-s`, `--skills <name>` | 为会话预加载一个或多个 skill（可重复或逗号分隔）。 |\n | `-v`, `--verbose` | 详细输出。 |\n | `-Q`, `--quiet` | 程序化模式：抑制横幅/spinner/工具预览。 |"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md","hunks":["@@ -63,9 +63,6 @@ description: \"Hermes Agent 使用的所有环境变量完整参考\"\n | `GOOGLE_API_KEY` | Google AI Studio API 密钥（[aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)） |\n | `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 |\n | `GEMINI_BASE_URL` | 覆盖 Google AI Studio base URL |\n-| `HERMES_GEMINI_CLIENT_ID` | `google-gemini-cli` PKCE 登录的 OAuth 客户端 ID（可选；默认使用 Google 公共 gemini-cli 客户端） |\n-| `HERMES_GEMINI_CLIENT_SECRET` | `google-gemini-cli` 的 OAuth 客户端密钥（可选） |\n-| `HERMES_GEMINI_PROJECT_ID` | 付费 Gemini 层级的 GCP 项目 ID（免费层级自动配置） |\n | `ANTHROPIC_API_KEY` | Anthropic Console API 密钥（[console.anthropic.com](https://console.anthropic.com/)） |\n | `ANTHROPIC_TOKEN` | 手动或旧版 Anthropic OAuth/setup-token 覆盖 |\n | `DASHSCOPE_API_KEY` | Qwen Cloud（阿里巴巴 DashScope）Qwen 模型 API 密钥（[modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)） |"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md","hunks":["@@ -20,7 +20,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商\n - **Nous Portal** — Nous Research 自有推理端点\n - **OpenAI** — GPT-5.4、GPT-5-codex、GPT-4.1、GPT-4o 等\n - **Anthropic** — Claude 模型（直接 API、通过 `hermes auth add anthropic` 进行 OAuth、OpenRouter 或任何兼容代理）\n-- **Google** — Gemini 模型（通过 `gemini` 提供商直接调用 API、`google-gemini-cli` OAuth 提供商、OpenRouter 或兼容代理）\n+- **Google** — Gemini 模型（通过 `gemini` 提供商直接调用 API、OpenRouter 或兼容代理）\n - **z.ai / ZhipuAI** — GLM 模型\n - **Kimi / Moonshot AI** — Kimi 模型\n - **MiniMax** — 全球及中国区端点"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md","hunks":["@@ -115,7 +115,6 @@ Hermes 有两个斜杠命令入口，均由 `hermes_cli/commands.py` 中的中\n | `/image <path>` | 为下一条 prompt 附加本地图片文件。 |\n | `/debug` | 上传调试报告（系统信息 + 日志）并获取可分享链接。消息平台中也可用。 |\n | `/profile` | 显示活动 profile 名称和主目录 |\n-| `/gquota` | 以进度条形式显示 Google Gemini Code Assist 配额用量（仅在 `google-gemini-cli` 提供商激活时可用）。 |\n \n ### 退出\n ","@@ -246,7 +245,7 @@ hermes config set model.aliases.grok x-ai/grok-4\n \n ## 注意事项\n \n-- `/skin`、`/snapshot`、`/gquota`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。\n+- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。\n - `/skills` **仅在搜索/浏览/安装时属于 CLI-only**；其写入审批子命令（`pending`、`approve`、`reject`、`diff`、`approval`）在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。\n - `/verbose` **默认仅限 CLI**，但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后，它会循环切换 `display.tool_progress` 模式并保存到配置。\n - `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md","hunks":["@@ -774,7 +774,7 @@ Hermes 中的每个模型槽位 —— 辅助任务、压缩、回退 —— 使\n \n 当设置 `base_url` 时，Hermes 忽略 provider 并直接调用该端点（使用 `api_key` 或 `OPENAI_API_KEY` 进行认证）。当仅设置 `provider` 时，Hermes 使用该 provider 的内置认证和基础 URL。\n \n-辅助任务的可用 providers：`auto`、`main`，以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider（例如 `provider: \"beans\"`）。\n+辅助任务的可用 providers：`auto`、`main`，以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider（例如 `provider: \"beans\"`）。\n \n :::tip MiniMax OAuth\n `minimax-oauth` 通过浏览器 OAuth 登录（无需 API 密钥）。运行 `hermes model` 并选择 **MiniMax (OAuth)** 进行认证。辅助任务自动使用 `MiniMax-M2.7-highspeed`。参阅 [MiniMax OAuth 指南](../guides/minimax-oauth.md)。"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md","hunks":["@@ -62,7 +62,6 @@ fallback_model:\n | GMI Cloud | `gmi` | `GMI_API_KEY`（可选：`GMI_BASE_URL`） |\n | StepFun | `stepfun` | `STEPFUN_API_KEY`（可选：`STEPFUN_BASE_URL`） |\n | Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |\n-| Google Gemini（OAuth） | `google-gemini-cli` | `hermes model`（Google OAuth；可选：`HERMES_GEMINI_PROJECT_ID`） |\n | Google AI Studio | `gemini` | `GOOGLE_API_KEY`（别名：`GEMINI_API_KEY`） |\n | xAI（Grok） | `xai`（别名 `grok`） | `XAI_API_KEY`（可选：`XAI_BASE_URL`） |\n | xAI Grok OAuth（SuperGrok） | `xai-oauth`（别名 `grok-oauth`） | `hermes model` → xAI Grok OAuth（浏览器登录；需 SuperGrok 订阅） |"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md","hunks":["@@ -332,7 +332,6 @@ hermes uninstall            Uninstall Hermes\n /commands [page]     Browse all commands (gateway)\n /usage               Token usage\n /insights [days]     Usage analytics\n-/gquota              Show Google Gemini Code Assist quota usage (CLI)\n /status              Session info (gateway)\n /profile             Active profile info\n /debug               Upload debug report (system info + logs) and get shareable links\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}