Gemini 429 error handling and provider cleanup

NousResearch/hermes-agent · cluster 15895 · agent

Data as of

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

The short answer

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

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #50492 feat(providers): remove google-gemini-cli + google-antigravity OAuth providers

    merged to main

    files: agent/agent_runtime_helpers.py, agent/antigravity_cloudcode_adapter.py, agent/antigravity_code_assist.py, agent/antigravity_oauth.py, agent/gemini_cloudcode_adapter.py, agent/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 (+48 more)

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

3 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 1

  • #30416 fix(provider/gemini): clarify RESOURCE_EXHAUSTED 429 error — distinguish free-tier routing from per-minute limits · superseded by another PR
  • #39077 fix(agent): distinguish transient Gemini rate limits from daily quota exhaustion · done elsewhere → 7130d60, #50492, #38804
  • #40755 fix(agent): add per-minute TPM/RPM cooldown tracker for Gemini 429 handling · done elsewhere → 7130d60, #15895
Closed issues · 2
  • Closed#15895[Bug]: google-gemini-cli causing 429 but gquota ok
  • Closed#38804[Bug]: Temporary Gemini rate limits are surfaced as "quota exhausted" errors
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

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

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#50492
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
22 Jun 2026

For your agent

The same facts, typed.